@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
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PAGE'S VIEW OF A RUNNING GAME'S COLYSEUS ROOMS — the editor's own networking adapter for a
|
|
3
|
+
* game that declares none (the ingest way: the game joins its room with `@colyseus/sdk` directly,
|
|
4
|
+
* and the editor reads it).
|
|
5
|
+
*
|
|
6
|
+
* The editor owns the page, so it wraps the page's `WebSocket` constructor once at boot, as it
|
|
7
|
+
* wraps `AudioContext` (`game-audio.ts`). A socket whose URL is a Colyseus room's
|
|
8
|
+
* (`/<processId>/<roomId>?sessionId=…`, the SDK's `buildEndpoint`) gets a MIRROR: every frame the
|
|
9
|
+
* game sends or receives is decoded the way the SDK's own `Room.onMessageCallback` decodes it —
|
|
10
|
+
* the join handshake and its schema reflection, full state and patches through the SDK's own
|
|
11
|
+
* `SchemaSerializer`, room messages as a type and a msgpack payload. The game's socket is never
|
|
12
|
+
* touched: the mirror reads copies of the same bytes and keeps its own decoder. The room's name is
|
|
13
|
+
* read from the matchmaking reply that reserved the seat (`POST /matchmake/<method>/<name>`).
|
|
14
|
+
*
|
|
15
|
+
* What a client cannot see, the observer does not claim: the other clients of a room are the
|
|
16
|
+
* server's (Colyseus Monitor reads them from the matchmaker), so `peers()` is the local session
|
|
17
|
+
* of each observed room.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { pack, unpack } from '@colyseus/msgpackr';
|
|
21
|
+
import { type DataChange, decode, encode, type Iterator } from '@colyseus/schema';
|
|
22
|
+
import { SchemaSerializer } from '@colyseus/sdk';
|
|
23
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
24
|
+
import { getPlayStartedAt, isPlayModeActive } from '../play/play-mode';
|
|
25
|
+
import type {
|
|
26
|
+
ConnectionState,
|
|
27
|
+
NetConditioning,
|
|
28
|
+
NetServerInspection,
|
|
29
|
+
NetMessageEvent,
|
|
30
|
+
NetPeer,
|
|
31
|
+
NetRates,
|
|
32
|
+
NetworkingAdapter,
|
|
33
|
+
ReplicationStats,
|
|
34
|
+
RoomInfo,
|
|
35
|
+
} from '@volter/editor-project/adapter/system-adapter';
|
|
36
|
+
|
|
37
|
+
/** Colyseus's wire codes (`@colyseus/shared-types`' `Protocol`). */
|
|
38
|
+
const JOIN_ROOM = 10;
|
|
39
|
+
const ERROR = 11;
|
|
40
|
+
const LEAVE_ROOM = 12;
|
|
41
|
+
const ROOM_DATA = 13;
|
|
42
|
+
const ROOM_STATE = 14;
|
|
43
|
+
const ROOM_STATE_PATCH = 15;
|
|
44
|
+
const ROOM_DATA_BYTES = 17;
|
|
45
|
+
const PING = 18;
|
|
46
|
+
|
|
47
|
+
const LOG_CAPACITY = 2000;
|
|
48
|
+
|
|
49
|
+
/** One message type's traffic, both directions (Godot's per-node RPC row). */
|
|
50
|
+
export interface ObservedMessageType {
|
|
51
|
+
readonly type: string;
|
|
52
|
+
readonly countIn: number;
|
|
53
|
+
readonly countOut: number;
|
|
54
|
+
readonly bytesIn: number;
|
|
55
|
+
readonly bytesOut: number;
|
|
56
|
+
readonly last?: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ObservedRoom {
|
|
60
|
+
readonly roomId: string;
|
|
61
|
+
readonly processId: string;
|
|
62
|
+
readonly sessionId: string;
|
|
63
|
+
readonly roomName: string | null;
|
|
64
|
+
readonly endpoint: string;
|
|
65
|
+
readonly state: ConnectionState;
|
|
66
|
+
readonly joinedAt: number | null;
|
|
67
|
+
readonly closedAt: number | null;
|
|
68
|
+
readonly closeCode: number | null;
|
|
69
|
+
readonly error: string | null;
|
|
70
|
+
readonly rttMs: number | null;
|
|
71
|
+
/** Bytes of the last full state the server sent, and of every patch since. */
|
|
72
|
+
readonly stateBytes: number;
|
|
73
|
+
readonly patches: number;
|
|
74
|
+
readonly patchBytes: number;
|
|
75
|
+
readonly messageTypes: readonly ObservedMessageType[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface MutableType {
|
|
79
|
+
type: string;
|
|
80
|
+
countIn: number;
|
|
81
|
+
countOut: number;
|
|
82
|
+
bytesIn: number;
|
|
83
|
+
bytesOut: number;
|
|
84
|
+
last?: unknown;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Mirror {
|
|
88
|
+
roomId: string;
|
|
89
|
+
processId: string;
|
|
90
|
+
sessionId: string;
|
|
91
|
+
endpoint: string;
|
|
92
|
+
state: ConnectionState;
|
|
93
|
+
joinedAt: number | null;
|
|
94
|
+
closedAt: number | null;
|
|
95
|
+
closeCode: number | null;
|
|
96
|
+
error: string | null;
|
|
97
|
+
serializer: SchemaSerializer | null;
|
|
98
|
+
pingSentAt: number | null;
|
|
99
|
+
rttMs: number | null;
|
|
100
|
+
stateBytes: number;
|
|
101
|
+
patches: number;
|
|
102
|
+
patchBytes: number;
|
|
103
|
+
types: Map<string, MutableType>;
|
|
104
|
+
entities: Map<string, MutableEntity>;
|
|
105
|
+
socket: WebSocket;
|
|
106
|
+
/** When the last delayed frame is due, per direction: a later frame never overtakes it. */
|
|
107
|
+
dueIn: number;
|
|
108
|
+
/** The game's own `onmessage`, handed each frame when the conditioned delay lets it through. */
|
|
109
|
+
receive: ((event: MessageEvent) => unknown) | null;
|
|
110
|
+
dueOut: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const mirrors: Mirror[] = [];
|
|
114
|
+
const mirrorOf = new WeakMap<WebSocket, Mirror>();
|
|
115
|
+
const gameHandlers = new WeakMap<WebSocket, ((this: WebSocket, event: MessageEvent) => unknown) | null>();
|
|
116
|
+
const pingWaiters = new WeakMap<Mirror, (rttMs: number) => void>();
|
|
117
|
+
/** The link conditioner (Unity's network simulator): latency and jitter on every observed room
|
|
118
|
+
* socket, both directions, in order. A WebSocket is reliable and ordered, so loss is not one of
|
|
119
|
+
* its behaviours and is not simulated. */
|
|
120
|
+
let conditioning: NetConditioning = { latencyMs: 0, jitterMs: 0, packetLoss: 0 };
|
|
121
|
+
|
|
122
|
+
/** Run `deliver` after the conditioned delay, never before the direction's previous frame. */
|
|
123
|
+
function conditioned(mirror: Mirror, direction: 'in' | 'out', deliver: () => void): void {
|
|
124
|
+
const { latencyMs, jitterMs } = conditioning;
|
|
125
|
+
const now = performance.now();
|
|
126
|
+
const last = direction === 'in' ? mirror.dueIn : mirror.dueOut;
|
|
127
|
+
if (latencyMs <= 0 && jitterMs <= 0 && last <= now) {
|
|
128
|
+
deliver();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const due = Math.max(last, now + latencyMs + Math.random() * jitterMs);
|
|
132
|
+
if (direction === 'in') mirror.dueIn = due;
|
|
133
|
+
else mirror.dueOut = due;
|
|
134
|
+
setTimeout(deliver, due - now);
|
|
135
|
+
}
|
|
136
|
+
const roomNames = new Map<string, string>();
|
|
137
|
+
const log: NetMessageEvent[] = [];
|
|
138
|
+
let seq = 0;
|
|
139
|
+
const listeners = new Set<() => void>();
|
|
140
|
+
/** Per-second windows for the rates: the current one and the last complete one. */
|
|
141
|
+
let window0 = { start: 0, msgsIn: 0, msgsOut: 0, bytesIn: 0, bytesOut: 0 };
|
|
142
|
+
let lastWindow = { msgsIn: 0, msgsOut: 0, bytesIn: 0, bytesOut: 0 };
|
|
143
|
+
|
|
144
|
+
function notify(): void {
|
|
145
|
+
for (const listener of listeners) listener();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function roll(now: number): void {
|
|
149
|
+
const elapsed = now - window0.start;
|
|
150
|
+
if (elapsed < 1000) return;
|
|
151
|
+
// The window that just closed is the last second's reading only if it closed within one.
|
|
152
|
+
lastWindow = elapsed < 2000 ? { ...window0 } : { msgsIn: 0, msgsOut: 0, bytesIn: 0, bytesOut: 0 };
|
|
153
|
+
window0 = { start: now, msgsIn: 0, msgsOut: 0, bytesIn: 0, bytesOut: 0 };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface MutableEntity {
|
|
157
|
+
path: string;
|
|
158
|
+
syncs: number;
|
|
159
|
+
changes: number;
|
|
160
|
+
patchBytesMin: number;
|
|
161
|
+
patchBytesMax: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Whether traffic and the log are tallied (Godot's profiler Start/Stop). */
|
|
165
|
+
let recording = false;
|
|
166
|
+
|
|
167
|
+
/** The project-local choice Godot calls Autostart: a run starts recording at its first room
|
|
168
|
+
* socket. Off unless chosen, as Godot's is. */
|
|
169
|
+
export const NETWORK_AUTOSTART_SECTION = 'networkProfilerAutostart';
|
|
170
|
+
/** The run (its Play start) Autostart last applied to. */
|
|
171
|
+
let autostartedRun: number | null | undefined;
|
|
172
|
+
|
|
173
|
+
/** Each object of a decoded state by its path from the root: schema fields and collection entries. */
|
|
174
|
+
function statePaths(state: unknown): Map<object, readonly string[]> {
|
|
175
|
+
const paths = new Map<object, readonly string[]>();
|
|
176
|
+
const visit = (value: unknown, path: readonly string[]): void => {
|
|
177
|
+
if (!value || typeof value !== 'object' || paths.has(value)) return;
|
|
178
|
+
paths.set(value, path);
|
|
179
|
+
const metadata = (value as { constructor?: Record<symbol, unknown> }).constructor?.[Symbol.metadata] as
|
|
180
|
+
| Record<string, unknown>
|
|
181
|
+
| undefined;
|
|
182
|
+
if (metadata) {
|
|
183
|
+
for (const key of Object.keys(metadata)) {
|
|
184
|
+
const name = /^\d+$/.test(key) ? (metadata[key] as { name?: string } | undefined)?.name : undefined;
|
|
185
|
+
if (name) visit((value as Record<string, unknown>)[name], [...path, name]);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
(value as { forEach?: (each: (child: unknown, key: unknown) => void) => void }).forEach?.((child, key) =>
|
|
190
|
+
visit(child, [...path, String(key)]),
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
visit(state, []);
|
|
194
|
+
return paths;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Tally one patch against the entities its changes touched: an entity is a root field, or an
|
|
198
|
+
* entry of a root collection (`players.abc`). */
|
|
199
|
+
function recordEntities(mirror: Mirror, changes: readonly DataChange[], size: number): void {
|
|
200
|
+
if (!recording || changes.length === 0) return;
|
|
201
|
+
const paths = statePaths(mirror.serializer?.getState());
|
|
202
|
+
const touched = new Map<string, number>();
|
|
203
|
+
for (const change of changes) {
|
|
204
|
+
const full = [...(paths.get(change.ref as object) ?? []), String(change.dynamicIndex ?? change.field)];
|
|
205
|
+
const entity = full.slice(0, full.length > 1 ? 2 : 1).join('.');
|
|
206
|
+
touched.set(entity, (touched.get(entity) ?? 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
for (const [path, count] of touched) {
|
|
209
|
+
let entry = mirror.entities.get(path);
|
|
210
|
+
if (!entry) {
|
|
211
|
+
entry = { path, syncs: 0, changes: 0, patchBytesMin: size, patchBytesMax: size };
|
|
212
|
+
mirror.entities.set(path, entry);
|
|
213
|
+
}
|
|
214
|
+
entry.syncs += 1;
|
|
215
|
+
entry.changes += count;
|
|
216
|
+
entry.patchBytesMin = Math.min(entry.patchBytesMin, size);
|
|
217
|
+
entry.patchBytesMax = Math.max(entry.patchBytesMax, size);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function record(mirror: Mirror, direction: 'in' | 'out', type: string, size: number, payload?: unknown): void {
|
|
222
|
+
if (!recording) return;
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
roll(now);
|
|
225
|
+
if (direction === 'in') {
|
|
226
|
+
window0.msgsIn += 1;
|
|
227
|
+
window0.bytesIn += size;
|
|
228
|
+
} else {
|
|
229
|
+
window0.msgsOut += 1;
|
|
230
|
+
window0.bytesOut += size;
|
|
231
|
+
}
|
|
232
|
+
let entry = mirror.types.get(type);
|
|
233
|
+
if (!entry) {
|
|
234
|
+
entry = { type, countIn: 0, countOut: 0, bytesIn: 0, bytesOut: 0 };
|
|
235
|
+
mirror.types.set(type, entry);
|
|
236
|
+
}
|
|
237
|
+
if (direction === 'in') {
|
|
238
|
+
entry.countIn += 1;
|
|
239
|
+
entry.bytesIn += size;
|
|
240
|
+
} else {
|
|
241
|
+
entry.countOut += 1;
|
|
242
|
+
entry.bytesOut += size;
|
|
243
|
+
}
|
|
244
|
+
if (payload !== undefined) entry.last = payload;
|
|
245
|
+
seq += 1;
|
|
246
|
+
// The contract's clock is `performance.now()` milliseconds.
|
|
247
|
+
log.push({ seq, time: performance.now(), direction, type, size });
|
|
248
|
+
if (log.length > LOG_CAPACITY) log.splice(0, log.length - LOG_CAPACITY);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A room message's type, as the SDK reads it: a string or a number after the code. */
|
|
252
|
+
function messageType(bytes: Uint8Array, it: Iterator): string {
|
|
253
|
+
const buffer = bytes as unknown as Parameters<typeof decode.string>[0];
|
|
254
|
+
return decode.stringCheck(buffer, it) ? decode.string(buffer, it) : String(decode.number(buffer, it));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function observeIncoming(mirror: Mirror, bytes: Uint8Array): void {
|
|
258
|
+
const code = bytes[0];
|
|
259
|
+
const it: Iterator = { offset: 1 };
|
|
260
|
+
const buffer = bytes as unknown as Parameters<typeof decode.utf8Read>[0];
|
|
261
|
+
try {
|
|
262
|
+
if (code === JOIN_ROOM) {
|
|
263
|
+
decode.utf8Read(buffer, it, bytes[it.offset++]!);
|
|
264
|
+
const serializerId = decode.utf8Read(buffer, it, bytes[it.offset++]!);
|
|
265
|
+
if (serializerId === 'schema' && !mirror.serializer) {
|
|
266
|
+
mirror.serializer = new SchemaSerializer();
|
|
267
|
+
if (bytes.byteLength > it.offset) mirror.serializer.handshake(bytes, it);
|
|
268
|
+
}
|
|
269
|
+
mirror.state = 'connected';
|
|
270
|
+
mirror.joinedAt ??= Date.now();
|
|
271
|
+
record(mirror, 'in', 'join', bytes.byteLength);
|
|
272
|
+
} else if (code === ERROR) {
|
|
273
|
+
const errorCode = decode.number(buffer, it);
|
|
274
|
+
const message = decode.string(buffer, it);
|
|
275
|
+
mirror.error = `${errorCode}: ${message}`;
|
|
276
|
+
mirror.state = 'error';
|
|
277
|
+
record(mirror, 'in', 'error', bytes.byteLength);
|
|
278
|
+
} else if (code === LEAVE_ROOM) {
|
|
279
|
+
record(mirror, 'in', 'leave', bytes.byteLength);
|
|
280
|
+
} else if (code === ROOM_STATE) {
|
|
281
|
+
mirror.serializer?.setState(bytes, it);
|
|
282
|
+
mirror.stateBytes = bytes.byteLength;
|
|
283
|
+
record(mirror, 'in', 'state', bytes.byteLength);
|
|
284
|
+
} else if (code === ROOM_STATE_PATCH) {
|
|
285
|
+
const decoder = mirror.serializer?.decoder;
|
|
286
|
+
let changes: readonly DataChange[] = [];
|
|
287
|
+
if (decoder) decoder.triggerChanges = (all) => (changes = all);
|
|
288
|
+
mirror.serializer?.patch(bytes, it);
|
|
289
|
+
recordEntities(mirror, changes, bytes.byteLength);
|
|
290
|
+
if (recording) {
|
|
291
|
+
mirror.patches += 1;
|
|
292
|
+
mirror.patchBytes += bytes.byteLength;
|
|
293
|
+
}
|
|
294
|
+
record(mirror, 'in', 'patch', bytes.byteLength);
|
|
295
|
+
} else if (code === ROOM_DATA) {
|
|
296
|
+
const type = messageType(bytes, it);
|
|
297
|
+
const payload = bytes.byteLength > it.offset ? unpack(bytes, { start: it.offset }) : undefined;
|
|
298
|
+
record(mirror, 'in', type, bytes.byteLength, payload);
|
|
299
|
+
} else if (code === ROOM_DATA_BYTES) {
|
|
300
|
+
record(mirror, 'in', messageType(bytes, it), bytes.byteLength);
|
|
301
|
+
} else if (code === PING) {
|
|
302
|
+
if (mirror.pingSentAt !== null) mirror.rttMs = Math.round(performance.now() - mirror.pingSentAt);
|
|
303
|
+
mirror.pingSentAt = null;
|
|
304
|
+
const waiting = pingWaiters.get(mirror);
|
|
305
|
+
pingWaiters.delete(mirror);
|
|
306
|
+
if (waiting && mirror.rttMs !== null) waiting(mirror.rttMs);
|
|
307
|
+
record(mirror, 'in', 'ping', bytes.byteLength);
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
// A frame the mirror cannot read is the mirror's failure, never the game's: the game decodes
|
|
311
|
+
// its own copy. Say so once per room rather than per frame.
|
|
312
|
+
if (!mirror.error) mirror.error = `the editor could not read a frame (code ${code}): ${String(error)}`;
|
|
313
|
+
}
|
|
314
|
+
notify();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function observeOutgoing(mirror: Mirror, data: unknown): void {
|
|
318
|
+
const bytes =
|
|
319
|
+
data instanceof Uint8Array
|
|
320
|
+
? data
|
|
321
|
+
: data instanceof ArrayBuffer
|
|
322
|
+
? new Uint8Array(data)
|
|
323
|
+
: ArrayBuffer.isView(data)
|
|
324
|
+
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
325
|
+
: null;
|
|
326
|
+
if (!bytes || bytes.byteLength === 0) return;
|
|
327
|
+
const code = bytes[0];
|
|
328
|
+
const it: Iterator = { offset: 1 };
|
|
329
|
+
try {
|
|
330
|
+
if (code === ROOM_DATA) {
|
|
331
|
+
const type = messageType(bytes, it);
|
|
332
|
+
const payload = bytes.byteLength > it.offset ? unpack(bytes, { start: it.offset }) : undefined;
|
|
333
|
+
record(mirror, 'out', type, bytes.byteLength, payload);
|
|
334
|
+
} else if (code === ROOM_DATA_BYTES) {
|
|
335
|
+
record(mirror, 'out', messageType(bytes, it), bytes.byteLength);
|
|
336
|
+
} else if (code === PING) {
|
|
337
|
+
mirror.pingSentAt = performance.now();
|
|
338
|
+
record(mirror, 'out', 'ping', bytes.byteLength);
|
|
339
|
+
} else if (code === JOIN_ROOM) {
|
|
340
|
+
record(mirror, 'out', 'join', bytes.byteLength);
|
|
341
|
+
} else if (code === LEAVE_ROOM) {
|
|
342
|
+
record(mirror, 'out', 'leave', bytes.byteLength);
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
/* the game's own send is unaffected */
|
|
346
|
+
}
|
|
347
|
+
notify();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The room a socket URL addresses, when it is a Colyseus room's. */
|
|
351
|
+
function roomAddress(url: string): { processId: string; roomId: string; sessionId: string; endpoint: string } | null {
|
|
352
|
+
let parsed: URL;
|
|
353
|
+
try {
|
|
354
|
+
parsed = new URL(url, location.href);
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const sessionId = parsed.searchParams.get('sessionId');
|
|
359
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
360
|
+
if (!sessionId || parts.length < 2) return null;
|
|
361
|
+
const roomId = parts[parts.length - 1]!;
|
|
362
|
+
const processId = parts[parts.length - 2]!;
|
|
363
|
+
return { processId, roomId, sessionId, endpoint: `${parsed.protocol}//${parsed.host}` };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function attach(socket: WebSocket, url: string): void {
|
|
367
|
+
const address = roomAddress(url);
|
|
368
|
+
if (!address) return;
|
|
369
|
+
const mirror: Mirror = {
|
|
370
|
+
...address,
|
|
371
|
+
state: 'connecting',
|
|
372
|
+
joinedAt: null,
|
|
373
|
+
closedAt: null,
|
|
374
|
+
closeCode: null,
|
|
375
|
+
error: null,
|
|
376
|
+
serializer: null,
|
|
377
|
+
pingSentAt: null,
|
|
378
|
+
rttMs: null,
|
|
379
|
+
stateBytes: 0,
|
|
380
|
+
patches: 0,
|
|
381
|
+
entities: new Map(),
|
|
382
|
+
patchBytes: 0,
|
|
383
|
+
types: new Map(),
|
|
384
|
+
socket,
|
|
385
|
+
dueIn: 0,
|
|
386
|
+
dueOut: 0,
|
|
387
|
+
receive: null,
|
|
388
|
+
};
|
|
389
|
+
// Autostart decides a RUN's recording once, at its first room socket; a reconnect inside the
|
|
390
|
+
// same run keeps whatever Start or Stop the person chose.
|
|
391
|
+
// Outside Play there is no run to key on: a socket opening with none live starts one.
|
|
392
|
+
const run = isPlayModeActive() ? getPlayStartedAt() : null;
|
|
393
|
+
const idle = !mirrors.some((other) => other.state === 'connected' || other.state === 'connecting');
|
|
394
|
+
if (run !== autostartedRun || (run === null && idle)) {
|
|
395
|
+
autostartedRun = run;
|
|
396
|
+
recording = editorHost().projectLocalState.read<boolean>(NETWORK_AUTOSTART_SECTION) ?? false;
|
|
397
|
+
}
|
|
398
|
+
mirrors.push(mirror);
|
|
399
|
+
mirrorOf.set(socket, mirror);
|
|
400
|
+
// ONE conditioned delivery per frame: the mirror reads it and the game receives it at the same
|
|
401
|
+
// moment, so the tables and Ping see the link the game sees (both directions of the delay).
|
|
402
|
+
socket.addEventListener('message', (event: MessageEvent) => {
|
|
403
|
+
conditioned(mirror, 'in', () => {
|
|
404
|
+
if (event.data instanceof ArrayBuffer) observeIncoming(mirror, new Uint8Array(event.data));
|
|
405
|
+
mirror.receive?.(event);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
socket.addEventListener('close', (event: CloseEvent) => {
|
|
409
|
+
mirror.state = 'disconnected';
|
|
410
|
+
mirror.closedAt = Date.now();
|
|
411
|
+
mirror.closeCode = event.code;
|
|
412
|
+
notify();
|
|
413
|
+
});
|
|
414
|
+
socket.addEventListener('error', () => {
|
|
415
|
+
mirror.state = 'error';
|
|
416
|
+
mirror.error ??= 'the socket reported an error';
|
|
417
|
+
notify();
|
|
418
|
+
});
|
|
419
|
+
const send = socket.send.bind(socket);
|
|
420
|
+
socket.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
|
|
421
|
+
observeOutgoing(mirror, data);
|
|
422
|
+
conditioned(mirror, 'out', () => {
|
|
423
|
+
if (socket.readyState === socket.OPEN) send(data);
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
notify();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Record the room name a matchmaking reply reserved a seat in. */
|
|
430
|
+
function observeMatchmaking(input: RequestInfo | URL, response: Response): void {
|
|
431
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
432
|
+
if (!/\/matchmake\//.test(url)) return;
|
|
433
|
+
void response
|
|
434
|
+
.clone()
|
|
435
|
+
.json()
|
|
436
|
+
.then((reservation: { name?: unknown; roomId?: unknown; room?: { name?: unknown; roomId?: unknown } }) => {
|
|
437
|
+
const room = reservation.room ?? reservation;
|
|
438
|
+
if (typeof room.roomId === 'string' && typeof room.name === 'string') {
|
|
439
|
+
roomNames.set(room.roomId, room.name);
|
|
440
|
+
notify();
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
.catch(() => undefined);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Install once at editor boot (`network-observer.service.ts`). Idempotent. */
|
|
447
|
+
export function installGameNetwork(): void {
|
|
448
|
+
const host = window as unknown as { __vgaiNetworkObserver?: boolean } & typeof window;
|
|
449
|
+
if (host.__vgaiNetworkObserver) return;
|
|
450
|
+
host.__vgaiNetworkObserver = true;
|
|
451
|
+
const Orig = window.WebSocket;
|
|
452
|
+
if (typeof Orig === 'function') {
|
|
453
|
+
const Observed = class extends Orig {
|
|
454
|
+
constructor(url: string | URL, protocols?: string | string[]) {
|
|
455
|
+
super(url, protocols);
|
|
456
|
+
attach(this, String(url));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// The SDK receives through `onmessage`; a room socket's handler is handed each frame by the
|
|
460
|
+
// mirror's conditioned delivery (`attach`), in the same turn the mirror reads it.
|
|
461
|
+
override set onmessage(handler: ((this: WebSocket, event: MessageEvent) => unknown) | null) {
|
|
462
|
+
const mirror = mirrorOf.get(this);
|
|
463
|
+
if (!mirror) {
|
|
464
|
+
super.onmessage = handler;
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
gameHandlers.set(this, handler);
|
|
468
|
+
mirror.receive = handler ? (event) => handler.call(this, event) : null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
override get onmessage(): ((this: WebSocket, event: MessageEvent) => unknown) | null {
|
|
472
|
+
return mirrorOf.has(this) ? (gameHandlers.get(this) ?? null) : super.onmessage;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
window.WebSocket = Observed as typeof WebSocket;
|
|
476
|
+
}
|
|
477
|
+
const fetchOrig = window.fetch;
|
|
478
|
+
if (typeof fetchOrig === 'function') {
|
|
479
|
+
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
480
|
+
const response = await fetchOrig(input, init);
|
|
481
|
+
observeMatchmaking(input, response);
|
|
482
|
+
return response;
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Every room this page has joined, newest last. */
|
|
488
|
+
export function observedRooms(): readonly ObservedRoom[] {
|
|
489
|
+
return mirrors.map((mirror) => ({
|
|
490
|
+
roomId: mirror.roomId,
|
|
491
|
+
processId: mirror.processId,
|
|
492
|
+
sessionId: mirror.sessionId,
|
|
493
|
+
roomName: roomNames.get(mirror.roomId) ?? null,
|
|
494
|
+
endpoint: mirror.endpoint,
|
|
495
|
+
state: mirror.state,
|
|
496
|
+
joinedAt: mirror.joinedAt,
|
|
497
|
+
closedAt: mirror.closedAt,
|
|
498
|
+
closeCode: mirror.closeCode,
|
|
499
|
+
error: mirror.error,
|
|
500
|
+
rttMs: mirror.rttMs,
|
|
501
|
+
stateBytes: mirror.stateBytes,
|
|
502
|
+
patches: mirror.patches,
|
|
503
|
+
patchBytes: mirror.patchBytes,
|
|
504
|
+
messageTypes: [...mirror.types.values()].map((entry) => ({ ...entry })),
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** A schema type as a person reads it: `float32`, `map<schema>`. A nested schema reads `schema`:
|
|
509
|
+
* the join handshake's reflection carries each type's fields, not the server's class name. */
|
|
510
|
+
function describeType(type: unknown): string {
|
|
511
|
+
if (typeof type === 'string') return type;
|
|
512
|
+
if (typeof type === 'function') return 'schema';
|
|
513
|
+
if (type && typeof type === 'object') {
|
|
514
|
+
const [kind, inner] = Object.entries(type as Record<string, unknown>)[0] ?? [];
|
|
515
|
+
if (kind) return `${kind}<${describeType(inner)}>`;
|
|
516
|
+
}
|
|
517
|
+
return 'unknown';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The declared type of the field at `path` in a decoded state, walking its schema metadata. */
|
|
521
|
+
function fieldType(state: unknown, path: readonly (string | number)[]): string | null {
|
|
522
|
+
let current: unknown = state;
|
|
523
|
+
let childType: unknown = null;
|
|
524
|
+
let type: unknown = null;
|
|
525
|
+
for (const segment of path) {
|
|
526
|
+
if (current === null || current === undefined) return null;
|
|
527
|
+
const metadata = (current as { constructor?: Record<symbol, unknown> }).constructor?.[Symbol.metadata] as
|
|
528
|
+
| Record<string | number, unknown>
|
|
529
|
+
| undefined;
|
|
530
|
+
if (metadata && typeof metadata[segment] === 'number') {
|
|
531
|
+
const field = metadata[metadata[segment] as number] as { type?: unknown } | undefined;
|
|
532
|
+
type = field?.type ?? null;
|
|
533
|
+
childType = type && typeof type === 'object' ? Object.values(type as Record<string, unknown>)[0] : null;
|
|
534
|
+
current = (current as Record<string | number, unknown>)[segment];
|
|
535
|
+
} else {
|
|
536
|
+
// A collection's entry takes the collection's declared child type.
|
|
537
|
+
type = childType;
|
|
538
|
+
childType = null;
|
|
539
|
+
const collection = current as { get?: (key: unknown) => unknown; at?: (index: number) => unknown };
|
|
540
|
+
current = collection.get ? collection.get(String(segment)) : collection.at ? collection.at(Number(segment)) : undefined;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return type === null ? null : describeType(type);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Colyseus Monitor's API on the room's own server, when the server mounts it (`/monitor`). */
|
|
547
|
+
function monitorApi(mirror: Mirror): string {
|
|
548
|
+
return `${mirror.endpoint.replace(/^ws/, 'http')}/monitor/api`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
interface MonitorRooms {
|
|
552
|
+
rooms?: { roomId: string; name: string; clients: number; maxClients?: string | number | null; locked?: boolean; elapsedTime?: number }[];
|
|
553
|
+
connections?: number;
|
|
554
|
+
cpu?: number;
|
|
555
|
+
memory?: { totalMemMb?: number; usedMemMb?: number };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
interface MonitorRoom {
|
|
559
|
+
clients?: { sessionId: string; elapsedTime: number }[];
|
|
560
|
+
stateSize?: number;
|
|
561
|
+
state?: unknown;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function monitorJson<T>(url: string): Promise<T | null> {
|
|
565
|
+
const response = await fetch(url).catch(() => null);
|
|
566
|
+
if (!response || !response.ok) return null;
|
|
567
|
+
return (await response.json().catch(() => null)) as T | null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** One of Monitor's room actions (`/room/call`), on `roomId` (the current room's by default). */
|
|
571
|
+
async function roomCall(method: string, args: unknown[], what: string, roomId?: string): Promise<void> {
|
|
572
|
+
const mirror = current();
|
|
573
|
+
if (!mirror) throw new Error('No room is observed.');
|
|
574
|
+
const query = new URLSearchParams({ roomId: roomId ?? mirror.roomId, method, args: JSON.stringify(args) });
|
|
575
|
+
const response = await fetch(`${monitorApi(mirror)}/room/call?${query}`);
|
|
576
|
+
if (response.status === 404) {
|
|
577
|
+
throw new Error(`The room server serves no Monitor view, so ${what} has nowhere to go (its \`server\` configuration sets VGAI_ROOM_MONITOR=1).`);
|
|
578
|
+
}
|
|
579
|
+
if (!response.ok) throw new Error(`The room server refused ${what} (${response.status}).`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** The room the inspector reads: the newest one still open, else the newest. */
|
|
583
|
+
function current(): Mirror | null {
|
|
584
|
+
for (let index = mirrors.length - 1; index >= 0; index -= 1) {
|
|
585
|
+
if (mirrors[index]!.state !== 'disconnected') return mirrors[index]!;
|
|
586
|
+
}
|
|
587
|
+
return mirrors[mirrors.length - 1] ?? null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function stateJson(mirror: Mirror | null): unknown {
|
|
591
|
+
const state = mirror?.serializer?.getState() as { toJSON?: () => unknown } | undefined;
|
|
592
|
+
return state?.toJSON ? state.toJSON() : state;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function rates(): NetRates {
|
|
596
|
+
roll(Date.now());
|
|
597
|
+
return {
|
|
598
|
+
msgsInPerSec: lastWindow.msgsIn,
|
|
599
|
+
msgsOutPerSec: lastWindow.msgsOut,
|
|
600
|
+
bytesInPerSec: lastWindow.bytesIn,
|
|
601
|
+
bytesOutPerSec: lastWindow.bytesOut,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Forget every room (the inspector's Clear). */
|
|
606
|
+
export function clearObservedNetwork(): void {
|
|
607
|
+
mirrors.splice(0, mirrors.length);
|
|
608
|
+
log.splice(0, log.length);
|
|
609
|
+
notify();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export const observedGameNetwork: NetworkingAdapter = {
|
|
613
|
+
peers(): NetPeer[] {
|
|
614
|
+
return mirrors
|
|
615
|
+
.filter((mirror) => mirror.state === 'connected')
|
|
616
|
+
.map((mirror) => ({ id: mirror.sessionId, label: `${roomNames.get(mirror.roomId) ?? mirror.roomId} (this client)` }));
|
|
617
|
+
},
|
|
618
|
+
networkId: () => null,
|
|
619
|
+
authority: () => 'server',
|
|
620
|
+
editable: () => false,
|
|
621
|
+
getConnectionState(): ConnectionState {
|
|
622
|
+
return current()?.state ?? 'disconnected';
|
|
623
|
+
},
|
|
624
|
+
getRoomInfo(): RoomInfo | null {
|
|
625
|
+
const mirror = current();
|
|
626
|
+
if (!mirror) return null;
|
|
627
|
+
return { roomId: mirror.roomId, sessionId: mirror.sessionId, roomName: roomNames.get(mirror.roomId) ?? '' };
|
|
628
|
+
},
|
|
629
|
+
getReplicationStats(): ReplicationStats {
|
|
630
|
+
const mirror = current();
|
|
631
|
+
const refs = (mirror?.serializer?.decoder as { root?: { refs?: Map<unknown, unknown> } } | undefined)?.root?.refs;
|
|
632
|
+
const now = rates();
|
|
633
|
+
return { entities: refs?.size ?? 0, msgsInPerSec: now.msgsInPerSec, msgsOutPerSec: now.msgsOutPerSec };
|
|
634
|
+
},
|
|
635
|
+
subscribe(callback) {
|
|
636
|
+
listeners.add(callback);
|
|
637
|
+
return () => {
|
|
638
|
+
listeners.delete(callback);
|
|
639
|
+
};
|
|
640
|
+
},
|
|
641
|
+
getStateSnapshot: () => stateJson(current()),
|
|
642
|
+
messageEvents(sinceSeq = 0): NetMessageEvent[] {
|
|
643
|
+
return log.filter((event) => event.seq > sinceSeq);
|
|
644
|
+
},
|
|
645
|
+
getRates: rates,
|
|
646
|
+
sendMessage(type: string, payload: unknown): void {
|
|
647
|
+
const mirror = current();
|
|
648
|
+
if (!mirror || mirror.state !== 'connected') throw new Error('No room is connected to send into.');
|
|
649
|
+
// The SDK's own framing (`Room.send`): the ROOM_DATA code, the type, then the msgpack payload.
|
|
650
|
+
const head = new Uint8Array(1 + 5 + type.length * 3);
|
|
651
|
+
head[0] = ROOM_DATA;
|
|
652
|
+
const it: Iterator = { offset: 1 };
|
|
653
|
+
encode.string(head as unknown as Parameters<typeof encode.string>[0], type, it);
|
|
654
|
+
const body = payload === undefined ? new Uint8Array(0) : new Uint8Array(pack(payload));
|
|
655
|
+
const frame = new Uint8Array(it.offset + body.byteLength);
|
|
656
|
+
frame.set(head.subarray(0, it.offset));
|
|
657
|
+
frame.set(body, it.offset);
|
|
658
|
+
// Through the game's own socket, so the send is observed and logged like any other.
|
|
659
|
+
mirror.socket.send(frame);
|
|
660
|
+
},
|
|
661
|
+
getConditioning: () => ({ ...conditioning }),
|
|
662
|
+
setConditioning(next: NetConditioning): void {
|
|
663
|
+
conditioning = { latencyMs: Math.max(0, next.latencyMs), jitterMs: Math.max(0, next.jitterMs), packetLoss: 0 };
|
|
664
|
+
notify();
|
|
665
|
+
},
|
|
666
|
+
getConditioningLimits: () => ({
|
|
667
|
+
packetLoss: 'A WebSocket is reliable and ordered: a lost packet is resent, so loss is not simulated here.',
|
|
668
|
+
}),
|
|
669
|
+
ping(): Promise<number> {
|
|
670
|
+
const mirror = current();
|
|
671
|
+
if (!mirror || mirror.state !== 'connected') return Promise.reject(new Error('No room is connected to ping.'));
|
|
672
|
+
return new Promise((resolve, reject) => {
|
|
673
|
+
pingWaiters.set(mirror, resolve);
|
|
674
|
+
setTimeout(() => {
|
|
675
|
+
if (pingWaiters.get(mirror) !== resolve) return;
|
|
676
|
+
pingWaiters.delete(mirror);
|
|
677
|
+
reject(new Error('The server did not answer the ping within 5 s.'));
|
|
678
|
+
}, 5000);
|
|
679
|
+
// The SDK's own PING frame (`Room.ping`), through the game's socket; the SDK ignores the
|
|
680
|
+
// answer to a ping it did not send.
|
|
681
|
+
mirror.socket.send(new Uint8Array([PING]));
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
async inspectServer(roomId?: string): Promise<NetServerInspection | null> {
|
|
685
|
+
const mirror = current();
|
|
686
|
+
if (!mirror) return null;
|
|
687
|
+
const api = monitorApi(mirror);
|
|
688
|
+
const list = await monitorJson<MonitorRooms>(`${api}/`);
|
|
689
|
+
if (!list) return null;
|
|
690
|
+
const inspected = roomId ?? mirror.roomId;
|
|
691
|
+
const detail = await monitorJson<MonitorRoom>(`${api}/room?roomId=${encodeURIComponent(inspected)}`);
|
|
692
|
+
const maxClients = (value: string | number | null | undefined): number | null => {
|
|
693
|
+
const count = Number(value);
|
|
694
|
+
return Number.isFinite(count) ? count : null;
|
|
695
|
+
};
|
|
696
|
+
return {
|
|
697
|
+
rooms: (list.rooms ?? []).map((room) => ({
|
|
698
|
+
roomId: room.roomId,
|
|
699
|
+
name: room.name,
|
|
700
|
+
clients: room.clients,
|
|
701
|
+
maxClients: maxClients(room.maxClients),
|
|
702
|
+
locked: room.locked === true,
|
|
703
|
+
elapsedMs: room.elapsedTime ?? 0,
|
|
704
|
+
})),
|
|
705
|
+
connections: list.connections ?? 0,
|
|
706
|
+
cpuPercent: typeof list.cpu === 'number' && Number.isFinite(list.cpu) ? list.cpu : null,
|
|
707
|
+
memory:
|
|
708
|
+
list.memory?.usedMemMb !== undefined && list.memory.totalMemMb !== undefined
|
|
709
|
+
? { usedMb: list.memory.usedMemMb, totalMb: list.memory.totalMemMb }
|
|
710
|
+
: null,
|
|
711
|
+
room: detail
|
|
712
|
+
? {
|
|
713
|
+
roomId: inspected,
|
|
714
|
+
clients: (detail.clients ?? []).map((client) => ({ sessionId: client.sessionId, elapsedMs: client.elapsedTime })),
|
|
715
|
+
stateBytes: detail.stateSize ?? 0,
|
|
716
|
+
...(detail.state !== undefined ? { state: detail.state } : {}),
|
|
717
|
+
}
|
|
718
|
+
: null,
|
|
719
|
+
};
|
|
720
|
+
},
|
|
721
|
+
disconnectClient: (sessionId: string, roomId?: string) =>
|
|
722
|
+
roomCall('_forceClientDisconnect', [sessionId], 'the disconnect', roomId),
|
|
723
|
+
editServerState: (path: readonly (string | number)[], value: unknown, roomId?: string) =>
|
|
724
|
+
roomCall('_editStateProperty', [path, value], 'the state edit', roomId),
|
|
725
|
+
deleteServerState: (path: readonly (string | number)[], roomId?: string) =>
|
|
726
|
+
roomCall('_deleteStateProperty', [path], 'the state delete', roomId),
|
|
727
|
+
sendToClient: (sessionId: string, type: string, payload: unknown, roomId?: string) =>
|
|
728
|
+
roomCall('_sendMessageToClient', [sessionId, type, payload], 'the send', roomId),
|
|
729
|
+
broadcast: (type: string, payload: unknown, roomId?: string) =>
|
|
730
|
+
roomCall('broadcast', [type, payload], 'the broadcast', roomId),
|
|
731
|
+
disposeRoom: (roomId?: string) => roomCall('disconnect', [], 'the dispose', roomId),
|
|
732
|
+
clearTraffic(): void {
|
|
733
|
+
for (const mirror of mirrors) {
|
|
734
|
+
mirror.types.clear();
|
|
735
|
+
mirror.entities.clear();
|
|
736
|
+
mirror.patches = 0;
|
|
737
|
+
mirror.patchBytes = 0;
|
|
738
|
+
}
|
|
739
|
+
notify();
|
|
740
|
+
},
|
|
741
|
+
stateFieldType(path: readonly (string | number)[]): string | null {
|
|
742
|
+
return fieldType(current()?.serializer?.getState(), path);
|
|
743
|
+
},
|
|
744
|
+
getTrafficByType() {
|
|
745
|
+
const mirror = current();
|
|
746
|
+
return mirror ? [...mirror.types.values()].map(({ type, countIn, countOut, bytesIn, bytesOut }) => ({ type, countIn, countOut, bytesIn, bytesOut })) : [];
|
|
747
|
+
},
|
|
748
|
+
getTrafficByEntity() {
|
|
749
|
+
const mirror = current();
|
|
750
|
+
return mirror
|
|
751
|
+
? [...mirror.entities.values()]
|
|
752
|
+
.map(({ path, syncs, changes, patchBytesMin, patchBytesMax }) => ({ path, syncs, changes, patchBytesMin, patchBytesMax }))
|
|
753
|
+
.sort((a, b) => b.syncs - a.syncs)
|
|
754
|
+
: [];
|
|
755
|
+
},
|
|
756
|
+
isRecording: () => recording,
|
|
757
|
+
setRecording(on: boolean): void {
|
|
758
|
+
recording = on;
|
|
759
|
+
notify();
|
|
760
|
+
},
|
|
761
|
+
getServerConfig() {
|
|
762
|
+
const mirror = current();
|
|
763
|
+
if (!mirror) return null;
|
|
764
|
+
const roomName = roomNames.get(mirror.roomId);
|
|
765
|
+
return roomName ? { endpoint: mirror.endpoint, roomName } : { endpoint: mirror.endpoint };
|
|
766
|
+
},
|
|
767
|
+
};
|