@woosh/meep-engine 2.138.0 → 2.138.1
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/build/bundle-worker-image-decoder.js +1 -1
- package/build/bundle-worker-terrain.js +1 -1
- package/package.json +1 -1
- package/src/core/assert.d.ts +6 -0
- package/src/core/assert.d.ts.map +1 -1
- package/src/core/assert.js +16 -3
- package/src/core/binary/half_to_float_uint16.js +1 -1
- package/src/core/binary/to_half_float_uint16.d.ts.map +1 -1
- package/src/core/binary/to_half_float_uint16.js +9 -4
- package/src/core/collection/table/RowFirstTableSpec.js +1 -1
- package/src/core/events/signal/Signal.d.ts.map +1 -1
- package/src/core/events/signal/Signal.js +53 -0
- package/src/core/math/spline/spline3_hermite_intersection_spline3_hermite_2d.d.ts +3 -3
- package/src/core/math/spline/spline3_hermite_intersection_spline3_hermite_2d.js +3 -3
- package/src/engine/Clock.d.ts +2 -2
- package/src/engine/Clock.js +2 -2
- package/src/engine/graphics/ecs/highlight/system/RenderableHighlightSystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/highlight/system/RenderableHighlightSystem.js +17 -29
- package/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.js +18 -31
- package/src/engine/network/NetworkSession.d.ts +386 -0
- package/src/engine/network/NetworkSession.d.ts.map +1 -0
- package/src/engine/network/NetworkSession.js +1841 -0
- package/src/engine/network/PriorityFetch.d.ts.map +1 -1
- package/src/engine/network/PriorityFetch.js +3 -2
- package/src/engine/network/adapters/QuaternionInterpolationAdapter.d.ts +14 -0
- package/src/engine/network/adapters/QuaternionInterpolationAdapter.d.ts.map +1 -0
- package/src/engine/network/adapters/QuaternionInterpolationAdapter.js +44 -0
- package/src/engine/network/adapters/TransformInterpolationAdapter.d.ts +18 -0
- package/src/engine/network/adapters/TransformInterpolationAdapter.d.ts.map +1 -0
- package/src/engine/network/adapters/TransformInterpolationAdapter.js +79 -0
- package/src/engine/network/adapters/TransformReplicationAdapter.d.ts +37 -0
- package/src/engine/network/adapters/TransformReplicationAdapter.d.ts.map +1 -0
- package/src/engine/network/adapters/TransformReplicationAdapter.js +87 -0
- package/src/engine/network/adapters/Vector3InterpolationAdapter.d.ts +18 -0
- package/src/engine/network/adapters/Vector3InterpolationAdapter.d.ts.map +1 -0
- package/src/engine/network/adapters/Vector3InterpolationAdapter.js +46 -0
- package/src/engine/network/convertPathToURL.js +107 -107
- package/src/engine/network/core/quantize/quantize_float.d.ts +54 -0
- package/src/engine/network/core/quantize/quantize_float.d.ts.map +1 -0
- package/src/engine/network/core/quantize/quantize_float.js +66 -0
- package/src/engine/network/core/quantize/quantize_position.d.ts +44 -0
- package/src/engine/network/core/quantize/quantize_position.d.ts.map +1 -0
- package/src/engine/network/core/quantize/quantize_position.js +54 -0
- package/src/engine/network/core/sequence/ack_bitfield.d.ts +47 -0
- package/src/engine/network/core/sequence/ack_bitfield.d.ts.map +1 -0
- package/src/engine/network/core/sequence/ack_bitfield.js +77 -0
- package/src/engine/network/core/sequence/seq16.d.ts +53 -0
- package/src/engine/network/core/sequence/seq16.d.ts.map +1 -0
- package/src/engine/network/core/sequence/seq16.js +69 -0
- package/src/engine/network/core/sequence/seq32.d.ts +55 -0
- package/src/engine/network/core/sequence/seq32.d.ts.map +1 -0
- package/src/engine/network/core/sequence/seq32.js +73 -0
- package/src/engine/network/diagnostics/BandwidthMeter.d.ts +76 -0
- package/src/engine/network/diagnostics/BandwidthMeter.d.ts.map +1 -0
- package/src/engine/network/diagnostics/BandwidthMeter.js +155 -0
- package/src/engine/network/diagnostics/ReplayLog.d.ts +74 -0
- package/src/engine/network/diagnostics/ReplayLog.d.ts.map +1 -0
- package/src/engine/network/diagnostics/ReplayLog.js +137 -0
- package/src/engine/network/diagnostics/SyncTest.d.ts +74 -0
- package/src/engine/network/diagnostics/SyncTest.d.ts.map +1 -0
- package/src/engine/network/diagnostics/SyncTest.js +151 -0
- package/src/engine/network/ecs/NetworkSystem.d.ts +57 -0
- package/src/engine/network/ecs/NetworkSystem.d.ts.map +1 -0
- package/src/engine/network/ecs/NetworkSystem.js +84 -0
- package/src/engine/network/ecs/components/NetworkIdentity.d.ts +58 -0
- package/src/engine/network/ecs/components/NetworkIdentity.d.ts.map +1 -0
- package/src/engine/network/ecs/components/NetworkIdentity.js +73 -0
- package/src/engine/network/ecs/serialization/NetworkIdentitySerializationAdapter.d.ts +40 -0
- package/src/engine/network/ecs/serialization/NetworkIdentitySerializationAdapter.d.ts.map +1 -0
- package/src/engine/network/ecs/serialization/NetworkIdentitySerializationAdapter.js +64 -0
- package/src/engine/network/orchestrator/NetworkPeer.d.ts +389 -0
- package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -0
- package/src/engine/network/orchestrator/NetworkPeer.js +1107 -0
- package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts +260 -0
- package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -0
- package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +425 -0
- package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +217 -0
- package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -0
- package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +562 -0
- package/src/engine/network/replication/Replicator.d.ts +134 -0
- package/src/engine/network/replication/Replicator.d.ts.map +1 -0
- package/src/engine/network/replication/Replicator.js +334 -0
- package/src/engine/network/replication/ScopeFilter.d.ts +64 -0
- package/src/engine/network/replication/ScopeFilter.d.ts.map +1 -0
- package/src/engine/network/replication/ScopeFilter.js +71 -0
- package/src/engine/network/sim/ActionLog.d.ts +94 -0
- package/src/engine/network/sim/ActionLog.d.ts.map +1 -0
- package/src/engine/network/sim/ActionLog.js +189 -0
- package/src/engine/network/sim/BinaryInterpolationAdapter.d.ts +58 -0
- package/src/engine/network/sim/BinaryInterpolationAdapter.d.ts.map +1 -0
- package/src/engine/network/sim/BinaryInterpolationAdapter.js +56 -0
- package/src/engine/network/sim/InterpolationLog.d.ts +165 -0
- package/src/engine/network/sim/InterpolationLog.d.ts.map +1 -0
- package/src/engine/network/sim/InterpolationLog.js +583 -0
- package/src/engine/network/sim/ReplicatedComponentRegistry.d.ts +59 -0
- package/src/engine/network/sim/ReplicatedComponentRegistry.d.ts.map +1 -0
- package/src/engine/network/sim/ReplicatedComponentRegistry.js +140 -0
- package/src/engine/network/sim/RewindEngine.d.ts +66 -0
- package/src/engine/network/sim/RewindEngine.d.ts.map +1 -0
- package/src/engine/network/sim/RewindEngine.js +182 -0
- package/src/engine/network/sim/SimAction.d.ts +133 -0
- package/src/engine/network/sim/SimAction.d.ts.map +1 -0
- package/src/engine/network/sim/SimAction.js +273 -0
- package/src/engine/network/sim/SimActionExecutor.d.ts +109 -0
- package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -0
- package/src/engine/network/sim/SimActionExecutor.js +238 -0
- package/src/engine/network/sim/SimActionRegistry.d.ts +60 -0
- package/src/engine/network/sim/SimActionRegistry.d.ts.map +1 -0
- package/src/engine/network/sim/SimActionRegistry.js +128 -0
- package/src/engine/network/sim/SmoothingState.d.ts +87 -0
- package/src/engine/network/sim/SmoothingState.d.ts.map +1 -0
- package/src/engine/network/sim/SmoothingState.js +223 -0
- package/src/engine/network/sim/Snapshotter.d.ts +98 -0
- package/src/engine/network/sim/Snapshotter.d.ts.map +1 -0
- package/src/engine/network/sim/Snapshotter.js +206 -0
- package/src/engine/network/sim/SpeculationLog.d.ts +53 -0
- package/src/engine/network/sim/SpeculationLog.d.ts.map +1 -0
- package/src/engine/network/sim/SpeculationLog.js +84 -0
- package/src/engine/network/state/Baseline.d.ts +48 -0
- package/src/engine/network/state/Baseline.d.ts.map +1 -0
- package/src/engine/network/state/Baseline.js +83 -0
- package/src/engine/network/state/ChangedEntitySet.d.ts +94 -0
- package/src/engine/network/state/ChangedEntitySet.d.ts.map +1 -0
- package/src/engine/network/state/ChangedEntitySet.js +256 -0
- package/src/engine/network/state/InputRing.d.ts +90 -0
- package/src/engine/network/state/InputRing.d.ts.map +1 -0
- package/src/engine/network/state/InputRing.js +173 -0
- package/src/engine/network/state/MutationLedger.d.ts +82 -0
- package/src/engine/network/state/MutationLedger.d.ts.map +1 -0
- package/src/engine/network/state/MutationLedger.js +182 -0
- package/src/engine/network/state/PriorityAccumulator.d.ts +104 -0
- package/src/engine/network/state/PriorityAccumulator.d.ts.map +1 -0
- package/src/engine/network/state/PriorityAccumulator.js +180 -0
- package/src/engine/network/state/ReplicationSlotTable.d.ts +78 -0
- package/src/engine/network/state/ReplicationSlotTable.d.ts.map +1 -0
- package/src/engine/network/state/ReplicationSlotTable.js +211 -0
- package/src/engine/network/time/AdaptiveRenderDelay.d.ts +128 -0
- package/src/engine/network/time/AdaptiveRenderDelay.d.ts.map +1 -0
- package/src/engine/network/time/AdaptiveRenderDelay.js +258 -0
- package/src/engine/network/time/JitterBuffer.d.ts +58 -0
- package/src/engine/network/time/JitterBuffer.d.ts.map +1 -0
- package/src/engine/network/time/JitterBuffer.js +116 -0
- package/src/engine/network/time/TimeDilation.d.ts +49 -0
- package/src/engine/network/time/TimeDilation.d.ts.map +1 -0
- package/src/engine/network/time/TimeDilation.js +62 -0
- package/src/engine/network/time/TimeSync.d.ts +68 -0
- package/src/engine/network/time/TimeSync.d.ts.map +1 -0
- package/src/engine/network/time/TimeSync.js +153 -0
- package/src/engine/network/transport/Channel.d.ts +74 -0
- package/src/engine/network/transport/Channel.d.ts.map +1 -0
- package/src/engine/network/transport/Channel.js +272 -0
- package/src/engine/network/transport/LoopbackTransport.d.ts +59 -0
- package/src/engine/network/transport/LoopbackTransport.d.ts.map +1 -0
- package/src/engine/network/transport/LoopbackTransport.js +194 -0
- package/src/engine/network/transport/ReliableCommandPipeline.d.ts +139 -0
- package/src/engine/network/transport/ReliableCommandPipeline.d.ts.map +1 -0
- package/src/engine/network/transport/ReliableCommandPipeline.js +291 -0
- package/src/engine/network/transport/Transport.d.ts +109 -0
- package/src/engine/network/transport/Transport.d.ts.map +1 -0
- package/src/engine/network/transport/Transport.js +119 -0
- package/src/engine/network/transport/adapters/NodeUDPTransport.d.ts +60 -0
- package/src/engine/network/transport/adapters/NodeUDPTransport.d.ts.map +1 -0
- package/src/engine/network/transport/adapters/NodeUDPTransport.js +206 -0
- package/src/engine/network/transport/adapters/SimulatedTransport.d.ts +110 -0
- package/src/engine/network/transport/adapters/SimulatedTransport.d.ts.map +1 -0
- package/src/engine/network/transport/adapters/SimulatedTransport.js +252 -0
- package/src/engine/network/transport/adapters/WebRTCDataChannelTransport.d.ts +33 -0
- package/src/engine/network/transport/adapters/WebRTCDataChannelTransport.d.ts.map +1 -0
- package/src/engine/network/transport/adapters/WebRTCDataChannelTransport.js +131 -0
- package/src/engine/network/transport/adapters/WebSocketTransport.d.ts +49 -0
- package/src/engine/network/transport/adapters/WebSocketTransport.d.ts.map +1 -0
- package/src/engine/network/transport/adapters/WebSocketTransport.js +180 -0
- package/src/engine/network/transport/adapters/WebTransportTransport.d.ts +73 -0
- package/src/engine/network/transport/adapters/WebTransportTransport.d.ts.map +1 -0
- package/src/engine/network/transport/adapters/WebTransportTransport.js +210 -0
- package/src/engine/network/transport/fragments/FragmentAssembler.d.ts +104 -0
- package/src/engine/network/transport/fragments/FragmentAssembler.d.ts.map +1 -0
- package/src/engine/network/transport/fragments/FragmentAssembler.js +291 -0
- package/src/engine/network/transport/fragments/FragmentRetention.d.ts +103 -0
- package/src/engine/network/transport/fragments/FragmentRetention.d.ts.map +1 -0
- package/src/engine/network/transport/fragments/FragmentRetention.js +194 -0
- package/src/engine/network/transport/fragments/fragment_send.d.ts +53 -0
- package/src/engine/network/transport/fragments/fragment_send.d.ts.map +1 -0
- package/src/engine/network/transport/fragments/fragment_send.js +147 -0
- package/src/engine/network/transport/fragments/packet_size.d.ts +93 -0
- package/src/engine/network/transport/fragments/packet_size.d.ts.map +1 -0
- package/src/engine/network/transport/fragments/packet_size.js +101 -0
- package/src/engine/network/xhr.js +23 -23
- package/src/engine/simulation/Ticker.d.ts +7 -0
- package/src/engine/simulation/Ticker.d.ts.map +1 -1
- package/src/engine/simulation/Ticker.js +15 -4
- package/src/engine/network/DataChannel.js +0 -1210
- package/src/engine/network/RemoteController.d.ts +0 -23
- package/src/engine/network/RemoteController.d.ts.map +0 -1
- package/src/engine/network/RemoteController.js +0 -114
- package/src/engine/network/remoteEditor.d.ts +0 -2
- package/src/engine/network/remoteEditor.d.ts.map +0 -1
- package/src/engine/network/remoteEditor.js +0 -142
|
@@ -0,0 +1,1841 @@
|
|
|
1
|
+
import { assert } from "../../core/assert.js";
|
|
2
|
+
import { BinaryBuffer } from "../../core/binary/BinaryBuffer.js";
|
|
3
|
+
import Signal from "../../core/events/signal/Signal.js";
|
|
4
|
+
import { EntityObserver } from "../ecs/EntityObserver.js";
|
|
5
|
+
import { UUID } from "../ecs/guid/UUID.js";
|
|
6
|
+
import { BinarySerializationRegistry, } from "../ecs/storage/binary/BinarySerializationRegistry.js";
|
|
7
|
+
|
|
8
|
+
import { NetworkIdentity } from "./ecs/components/NetworkIdentity.js";
|
|
9
|
+
import { NetworkSystem } from "./ecs/NetworkSystem.js";
|
|
10
|
+
import { NetworkIdentitySerializationAdapter, } from "./ecs/serialization/NetworkIdentitySerializationAdapter.js";
|
|
11
|
+
import { NetworkPeer, ResumeRejectReason, } from "./orchestrator/NetworkPeer.js";
|
|
12
|
+
import { ServerAuthoritativeClient } from "./orchestrator/ServerAuthoritativeClient.js";
|
|
13
|
+
import { ServerAuthoritativeServer } from "./orchestrator/ServerAuthoritativeServer.js";
|
|
14
|
+
import { OwnerAwareScope } from "./replication/ScopeFilter.js";
|
|
15
|
+
import { BinaryInterpolationAdapter, InterpolationKind, } from "./sim/BinaryInterpolationAdapter.js";
|
|
16
|
+
import { InterpolationLog } from "./sim/InterpolationLog.js";
|
|
17
|
+
import { SimAction } from "./sim/SimAction.js";
|
|
18
|
+
import { snapshotter_emit } from "./sim/Snapshotter.js";
|
|
19
|
+
import { AdaptiveRenderDelay } from "./time/AdaptiveRenderDelay.js";
|
|
20
|
+
import { TimeDilation } from "./time/TimeDilation.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Roles a session can occupy. The orchestrator it wires up underneath is
|
|
24
|
+
* uniquely determined by the role.
|
|
25
|
+
*
|
|
26
|
+
* - `'client'` → {@link ServerAuthoritativeClient}: predicts locally
|
|
27
|
+
* when an input sampler is registered; reconciles to
|
|
28
|
+
* server-authoritative state arriving via AUTH_STATE.
|
|
29
|
+
* With no input sampler, a client is a pure spectator
|
|
30
|
+
* — it receives the action stream, smooths via
|
|
31
|
+
* interpolation, and never predicts or rewinds. This
|
|
32
|
+
* is the default role and covers both "playing" and
|
|
33
|
+
* "spectating" use cases.
|
|
34
|
+
* - `'host'` → {@link ServerAuthoritativeServer}: simulation
|
|
35
|
+
* authority for one or more clients; runs the optional
|
|
36
|
+
* server-side input buffer (`simulation_delay_ticks`).
|
|
37
|
+
*
|
|
38
|
+
* @readonly @enum {string}
|
|
39
|
+
*/
|
|
40
|
+
export const NetworkSessionRole = Object.freeze({
|
|
41
|
+
Client: 'client',
|
|
42
|
+
Host: 'host',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const DEFAULT_TICK_RATE_HZ = 60;
|
|
46
|
+
const DEFAULT_SIMULATION_DELAY_TICKS = 4;
|
|
47
|
+
const DEFAULT_INITIAL_RENDER_DELAY_FRAMES = 6;
|
|
48
|
+
const PULL_PER_MS = 0.0005; // wall-clock low-pass on render frame state machine
|
|
49
|
+
const MAX_CLIENT_DILATED_TICKS_PER_STEP = 3;
|
|
50
|
+
const MAX_FIXED_STEPS_PER_TICK = 8;
|
|
51
|
+
|
|
52
|
+
const DEFAULT_SERVER_RESUME_GRACE_MS = 30_000;
|
|
53
|
+
|
|
54
|
+
const DEFAULT_RECONNECT_POLICY = Object.freeze({
|
|
55
|
+
enabled: true,
|
|
56
|
+
max_attempts: 8,
|
|
57
|
+
base_delay_ms: 200,
|
|
58
|
+
max_delay_ms: 5_000,
|
|
59
|
+
exponential_factor: 2.0,
|
|
60
|
+
total_timeout_ms: 60_000,
|
|
61
|
+
accept_state_resync: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Client-side reconnect state machine values.
|
|
66
|
+
* @readonly @enum {string}
|
|
67
|
+
*/
|
|
68
|
+
const ReconnectState = Object.freeze({
|
|
69
|
+
Connected: 'connected',
|
|
70
|
+
Reconnecting: 'reconnecting',
|
|
71
|
+
Resyncing: 'resyncing',
|
|
72
|
+
Lost: 'lost',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Scratch buffer size used for AUTH_STATE assembly and net_mutate_component
|
|
77
|
+
* event payloads. Sized for the common cases (Transform ≈ 36 B); components
|
|
78
|
+
* with richer payloads need this raised or a different scratch lifecycle.
|
|
79
|
+
*/
|
|
80
|
+
const SCRATCH_BUFFER_BYTES = 1024;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* High-level networking facade. Wraps the orchestrator + state machinery
|
|
84
|
+
* into one config + lifecycle surface. The lower-level orchestrators
|
|
85
|
+
* remain reachable through `session.server` / `session.client` /
|
|
86
|
+
* `session.peer` for callers that need to override defaults post-hoc.
|
|
87
|
+
*
|
|
88
|
+
* Replication is component-driven: attaching a {@link NetworkIdentity}
|
|
89
|
+
* to an entity registers it with the session via an `EntityObserver`.
|
|
90
|
+
* Mutation is event-driven: callers fire
|
|
91
|
+
* `dataset.sendEvent(entity, "net_mutate_component", payload)`.
|
|
92
|
+
*
|
|
93
|
+
* Per-tick driving is a single `session.tick(dt_seconds)`. The session
|
|
94
|
+
* runs fixed-timestep stepping, client time dilation, server-side input
|
|
95
|
+
* buffer, AUTH_STATE dispatch, time-dilation feedback, and interpolated
|
|
96
|
+
* rendering for remote-owned entities.
|
|
97
|
+
*
|
|
98
|
+
* @author Alex Goldring
|
|
99
|
+
* @copyright Company Named Limited (c) 2025
|
|
100
|
+
*/
|
|
101
|
+
export class NetworkSession {
|
|
102
|
+
|
|
103
|
+
// ---- Pre-start registration (set via replicate / defineAction / defineInputSampler) ----
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* User-registered replicated component classes, in `replicate()` call
|
|
107
|
+
* order. Finalized at `start()` into {@link #replicated} with
|
|
108
|
+
* `NetworkIdentity` prepended.
|
|
109
|
+
* @private @type {Array<{klass: Function, interp: BinaryInterpolationAdapter|null}>}
|
|
110
|
+
*/
|
|
111
|
+
#user_replicated = [];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Finalized at `start()`. Insertion order = wire-format order;
|
|
115
|
+
* `NetworkIdentity` is the first entry (its slot allocation must
|
|
116
|
+
* fire before subsequent components attach during initial-sync apply).
|
|
117
|
+
* Each value: `{ interp, interp_type_id }`.
|
|
118
|
+
* @private @type {Map<Function, {interp: BinaryInterpolationAdapter|null, interp_type_id: number}>}
|
|
119
|
+
*/
|
|
120
|
+
#replicated = new Map();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Type_id → ComponentClass for the synthetic ReplaceComponentAction.
|
|
124
|
+
* @private @type {Function[]}
|
|
125
|
+
*/
|
|
126
|
+
#interp_type_id_to_class = [];
|
|
127
|
+
|
|
128
|
+
/** @private @type {Array<Function>} */
|
|
129
|
+
#user_action_classes = [];
|
|
130
|
+
|
|
131
|
+
/** @private @type {((frame: number) => Array<SimAction>|null) | null} */
|
|
132
|
+
#input_sampler = null;
|
|
133
|
+
|
|
134
|
+
// ---- Set in start() ----
|
|
135
|
+
|
|
136
|
+
/** @private @type {ServerAuthoritativeServer|null} */
|
|
137
|
+
#server = null;
|
|
138
|
+
/** @private @type {ServerAuthoritativeClient|null} */
|
|
139
|
+
#client = null;
|
|
140
|
+
/** @private @type {NetworkPeer|null} */
|
|
141
|
+
#peer = null;
|
|
142
|
+
/** @private @type {EntityObserver|null} */
|
|
143
|
+
#identity_observer = null;
|
|
144
|
+
/** @private @type {InterpolationLog|null} */
|
|
145
|
+
#interp_log = null;
|
|
146
|
+
/** @private @type {Function|null} */
|
|
147
|
+
#ReplaceComponentAction = null;
|
|
148
|
+
|
|
149
|
+
// ---- Per-entity tracking ----
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `entity_id → "net_mutate_component" listener` so detach can clean up.
|
|
153
|
+
* Also doubles as the iterable of all networked entities.
|
|
154
|
+
* @private @type {Map<number, Function>}
|
|
155
|
+
*/
|
|
156
|
+
#event_listeners = new Map();
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Entities the local peer does NOT own — driven by the render
|
|
160
|
+
* interpolation pass.
|
|
161
|
+
* @private @type {Set<number>}
|
|
162
|
+
*/
|
|
163
|
+
#remote_entities = new Set();
|
|
164
|
+
|
|
165
|
+
// ---- Render-frame interpolation state ----
|
|
166
|
+
|
|
167
|
+
/** @private @type {number} */ #latest_received_frame = -1;
|
|
168
|
+
/** @private @type {number} */ #smooth_render_frame = -1;
|
|
169
|
+
/** @private @type {number} */ #smooth_last_wall_ms = -1;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Set after `#render_interpolated_entities` writes interp values into
|
|
173
|
+
* live components; cleared by `normalize_if_dirty`. Drives the
|
|
174
|
+
* canonical-form invariant: live is canonical when this is false,
|
|
175
|
+
* polluted (smooth) when true.
|
|
176
|
+
* @private @type {boolean}
|
|
177
|
+
*/
|
|
178
|
+
render_dirty = false;
|
|
179
|
+
|
|
180
|
+
// ---- Per-tick driver state ----
|
|
181
|
+
|
|
182
|
+
/** @private @type {number} */ #local_frame = 0;
|
|
183
|
+
/** @private @type {number} */ #local_frame_accum = 0;
|
|
184
|
+
/** @private @type {number} */ #sim_accum_ms = 0;
|
|
185
|
+
|
|
186
|
+
/** @private @type {Set<number>} */
|
|
187
|
+
#connected_peers = new Set();
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Host-only: peer_id → remaining-resends. Drained in
|
|
191
|
+
* `#on_host_tick_complete` once `current_sim_frame >= 0`. Sent twice
|
|
192
|
+
* for loss tolerance; production code should re-send on receiver
|
|
193
|
+
* RECOVERY request.
|
|
194
|
+
* @private @type {Map<number, number>}
|
|
195
|
+
*/
|
|
196
|
+
#pending_initial_sync = new Map();
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Sampled-action ledger for predict-reconcile replay. The sampler
|
|
200
|
+
* reads live input, so re-calling it during reconcile would give
|
|
201
|
+
* wrong results; replay reads from here instead.
|
|
202
|
+
* @private @type {Map<number, Array<{type_id: number, bytes: Uint8Array}>>}
|
|
203
|
+
*/
|
|
204
|
+
#sampled_actions_per_frame = new Map();
|
|
205
|
+
|
|
206
|
+
/** @private @type {boolean} */
|
|
207
|
+
#started = false;
|
|
208
|
+
|
|
209
|
+
// ---- Host-side: per-peer session token + grace state ----
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Tokens issued at first INITIAL_SYNC, kept while the peer is
|
|
213
|
+
* connected or in the grace window. Used to authenticate
|
|
214
|
+
* RESUME_HELLO.
|
|
215
|
+
* @private @type {Map<number, UUID>}
|
|
216
|
+
*/
|
|
217
|
+
#peer_session_tokens = new Map();
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Peers currently in the post-disconnect grace window. Value carries
|
|
221
|
+
* the deadline (wall-clock ms) and the cached `last_acked_frame` so
|
|
222
|
+
* a successful resume restores the action-stream baseline.
|
|
223
|
+
* @private @type {Map<number, { deadline_ms: number, last_acked: number }>}
|
|
224
|
+
*/
|
|
225
|
+
#peer_grace_state = new Map();
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Per-peer transport onDisconnect listener bookkeeping so we can
|
|
229
|
+
* unsubscribe when the peer is fully dropped.
|
|
230
|
+
* @private @type {Map<number, { transport: object, listener: Function }>}
|
|
231
|
+
*/
|
|
232
|
+
#peer_transport_listeners = new Map();
|
|
233
|
+
|
|
234
|
+
// ---- Client-side: session token + reconnect state machine ----
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Server-issued session token (raw UUID v1 bytes). Set on first
|
|
238
|
+
* INITIAL_SYNC arrival; refreshed on every Tier-3 resync. Sent in
|
|
239
|
+
* `RESUME_HELLO` to claim continuity with a prior session record.
|
|
240
|
+
* Observable to game code for diagnostics; not auth-grade.
|
|
241
|
+
* @type {UUID|null}
|
|
242
|
+
*/
|
|
243
|
+
session_token = null;
|
|
244
|
+
|
|
245
|
+
/** @private @type {string} */
|
|
246
|
+
#reconnect_state = ReconnectState.Connected;
|
|
247
|
+
/** @private @type {number} */
|
|
248
|
+
#reconnect_attempt = 0;
|
|
249
|
+
/** @private @type {number} */
|
|
250
|
+
#reconnect_elapsed_ms = 0;
|
|
251
|
+
/** @private @type {number} */
|
|
252
|
+
#reconnect_next_delay_ms = 0;
|
|
253
|
+
/** @private @type {number} */
|
|
254
|
+
#reconnect_timer_remaining_ms = 0;
|
|
255
|
+
/** @private @type {object|null} */
|
|
256
|
+
#current_transport = null;
|
|
257
|
+
/** @private @type {Function|null} */
|
|
258
|
+
#current_transport_listener = null;
|
|
259
|
+
/** @private @type {number} */
|
|
260
|
+
#remote_peer_id = -1;
|
|
261
|
+
|
|
262
|
+
// ---- Signals (initialized inline because they take no input) ----
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Host: a connected peer's transport reported a disconnect (the peer
|
|
266
|
+
* has entered the grace window). Args: `(peer_id, reason)`.
|
|
267
|
+
* @type {Signal}
|
|
268
|
+
*/
|
|
269
|
+
onPeerLost = new Signal();
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Host: a peer has been permanently freed — either via
|
|
273
|
+
* `drop_peer()`, a grace-window timeout, or a peer-initiated
|
|
274
|
+
* DISCONNECT. Args: `(peer_id, reason)`.
|
|
275
|
+
* @type {Signal}
|
|
276
|
+
*/
|
|
277
|
+
onPeerPermanentlyDropped = new Signal();
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Client: the transport has reported a disconnect, and the
|
|
281
|
+
* reconnect ladder has started. Args: `(reason)`.
|
|
282
|
+
* @type {Signal}
|
|
283
|
+
*/
|
|
284
|
+
onConnectionLost = new Signal();
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Client: about to rebuild the transport and dial the host. Args:
|
|
288
|
+
* `(attempt_number)`.
|
|
289
|
+
* @type {Signal}
|
|
290
|
+
*/
|
|
291
|
+
onReconnectAttempt = new Signal();
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Client: reconnect succeeded. Args: `({ state_resynced: boolean })` —
|
|
295
|
+
* `state_resynced` is `true` if we fell through to Tier-3 (server
|
|
296
|
+
* had purged our state and re-sent INITIAL_SYNC), `false` for the
|
|
297
|
+
* happy Tier-2 path where the action stream just resumed.
|
|
298
|
+
* @type {Signal}
|
|
299
|
+
*/
|
|
300
|
+
onReconnected = new Signal();
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Client: the reconnect ladder is exhausted, or the host explicitly
|
|
304
|
+
* kicked us with a DISCONNECT packet, or `reconnect.enabled` is
|
|
305
|
+
* false and the link dropped. Args: `(reason)`.
|
|
306
|
+
* @type {Signal}
|
|
307
|
+
*/
|
|
308
|
+
onConnectionPermanentlyLost = new Signal();
|
|
309
|
+
|
|
310
|
+
// ---- Set in constructor (depend on input or imperative setup) ----
|
|
311
|
+
|
|
312
|
+
/** @private */ #transport = null;
|
|
313
|
+
/** @private */ #scope_filter = null;
|
|
314
|
+
/** @private @type {TimeDilation} */ #time_dilation;
|
|
315
|
+
/** @private @type {AdaptiveRenderDelay} */ #adaptive_render_delay;
|
|
316
|
+
/** @private @type {BinaryBuffer} */ #scratch_send_buf;
|
|
317
|
+
/** @private @type {BinaryBuffer} */ #scratch_interp_buf;
|
|
318
|
+
/** @private @type {() => void} */ #bound_normalize_if_dirty;
|
|
319
|
+
|
|
320
|
+
/** @private @type {number} */ #server_resume_grace_ms;
|
|
321
|
+
/** @private @type {object} */ #reconnect_policy;
|
|
322
|
+
/** @private @type {(() => object) | null} */ #transport_factory;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {{
|
|
326
|
+
* entity_manager: EntityManager,
|
|
327
|
+
* transport?: object,
|
|
328
|
+
* transport_factory?: (() => object),
|
|
329
|
+
* role?: 'client'|'host',
|
|
330
|
+
* local_peer_id?: number,
|
|
331
|
+
* simulation_delay_ticks?: number,
|
|
332
|
+
* tick_rate_hz?: number,
|
|
333
|
+
* binary_registry?: BinarySerializationRegistry,
|
|
334
|
+
* scope_filter?: object,
|
|
335
|
+
* time_dilation?: TimeDilation,
|
|
336
|
+
* adaptive_render_delay?: AdaptiveRenderDelay,
|
|
337
|
+
* server_resume_grace_ms?: number,
|
|
338
|
+
* reconnect?: {
|
|
339
|
+
* enabled?: boolean,
|
|
340
|
+
* max_attempts?: number,
|
|
341
|
+
* base_delay_ms?: number,
|
|
342
|
+
* max_delay_ms?: number,
|
|
343
|
+
* exponential_factor?: number,
|
|
344
|
+
* total_timeout_ms?: number,
|
|
345
|
+
* accept_state_resync?: boolean,
|
|
346
|
+
* },
|
|
347
|
+
* }} options
|
|
348
|
+
*
|
|
349
|
+
* - `transport` is not connected at construction — call {@link connect}
|
|
350
|
+
* with an explicit remote peer id after {@link start}.
|
|
351
|
+
* - `transport_factory` (client role): zero-arg function that returns
|
|
352
|
+
* a fresh `Transport` on each reconnect attempt. Required for
|
|
353
|
+
* automatic reconnect; without it, transport-level disconnects fall
|
|
354
|
+
* straight through to `onConnectionPermanentlyLost`.
|
|
355
|
+
* - `role` defaults to `'client'`. See {@link NetworkSessionRole}.
|
|
356
|
+
* - `local_peer_id` defaults to 0 for host, 1 for client. Must be
|
|
357
|
+
* unique across peers.
|
|
358
|
+
* - `simulation_delay_ticks` is honored only under `role: 'host'`.
|
|
359
|
+
* Default 4. See {@link ServerAuthoritativeServer}.
|
|
360
|
+
* - `tick_rate_hz` defaults to 60.
|
|
361
|
+
* - `scope_filter` defaults to {@link OwnerAwareScope} on the host.
|
|
362
|
+
* - `server_resume_grace_ms` (host role): how long peer state is
|
|
363
|
+
* retained after a transport drop before being freed. Default 30s.
|
|
364
|
+
* - `reconnect` (client role): policy for the reconnect ladder. See
|
|
365
|
+
* {@link DEFAULT_RECONNECT_POLICY}. Set `enabled: false` to opt
|
|
366
|
+
* out entirely.
|
|
367
|
+
*/
|
|
368
|
+
constructor({
|
|
369
|
+
entity_manager,
|
|
370
|
+
transport = null,
|
|
371
|
+
transport_factory = null,
|
|
372
|
+
role = NetworkSessionRole.Client,
|
|
373
|
+
local_peer_id = -1,
|
|
374
|
+
simulation_delay_ticks = DEFAULT_SIMULATION_DELAY_TICKS,
|
|
375
|
+
tick_rate_hz = DEFAULT_TICK_RATE_HZ,
|
|
376
|
+
binary_registry = null,
|
|
377
|
+
scope_filter = null,
|
|
378
|
+
time_dilation = null,
|
|
379
|
+
adaptive_render_delay = null,
|
|
380
|
+
server_resume_grace_ms = DEFAULT_SERVER_RESUME_GRACE_MS,
|
|
381
|
+
reconnect = null,
|
|
382
|
+
} = {}) {
|
|
383
|
+
assert.ok(entity_manager !== undefined && entity_manager !== null,
|
|
384
|
+
'NetworkSession: entity_manager is required');
|
|
385
|
+
if (role !== NetworkSessionRole.Client && role !== NetworkSessionRole.Host) {
|
|
386
|
+
throw new Error(`NetworkSession: invalid role '${role}'`);
|
|
387
|
+
}
|
|
388
|
+
assert.isNumber(tick_rate_hz, 'tick_rate_hz');
|
|
389
|
+
assert.ok(tick_rate_hz > 0, 'tick_rate_hz must be positive');
|
|
390
|
+
assert.isNonNegativeInteger(simulation_delay_ticks, 'simulation_delay_ticks');
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @readonly
|
|
394
|
+
* @type {EntityManager}
|
|
395
|
+
*/
|
|
396
|
+
this.entity_manager = entity_manager;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* @readonly
|
|
400
|
+
* @type {EntityComponentDataset}
|
|
401
|
+
*/
|
|
402
|
+
this.world = entity_manager.dataset;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @readonly
|
|
406
|
+
* @type {NetworkSessionRole|string}
|
|
407
|
+
*/
|
|
408
|
+
this.role = role;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* @readonly
|
|
412
|
+
* @type {number}
|
|
413
|
+
*/
|
|
414
|
+
this.tick_period_ms = 1000 / tick_rate_hz;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* @readonly
|
|
418
|
+
* @type {number}
|
|
419
|
+
*/
|
|
420
|
+
this.simulation_delay_ticks = role === NetworkSessionRole.Host
|
|
421
|
+
? simulation_delay_ticks
|
|
422
|
+
: 0;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* @readonly
|
|
426
|
+
* @type {number}
|
|
427
|
+
*/
|
|
428
|
+
this.local_peer_id = local_peer_id >= 0
|
|
429
|
+
? local_peer_id
|
|
430
|
+
: (role === NetworkSessionRole.Host ? 0 : 1);
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @readonly
|
|
434
|
+
* @type {BinarySerializationRegistry}
|
|
435
|
+
*/
|
|
436
|
+
this.binary_registry = binary_registry || new BinarySerializationRegistry();
|
|
437
|
+
|
|
438
|
+
this.#transport = transport;
|
|
439
|
+
this.#scope_filter = scope_filter;
|
|
440
|
+
|
|
441
|
+
this.#time_dilation = time_dilation || new TimeDilation({
|
|
442
|
+
target_buffer_depth: this.simulation_delay_ticks > 0 ? this.simulation_delay_ticks : 2,
|
|
443
|
+
max_dilation: 0.05,
|
|
444
|
+
gain: 0.05,
|
|
445
|
+
});
|
|
446
|
+
this.#adaptive_render_delay = adaptive_render_delay || new AdaptiveRenderDelay({
|
|
447
|
+
tick_period_ms: this.tick_period_ms,
|
|
448
|
+
min_delay_frames: 2,
|
|
449
|
+
max_delay_frames: 30,
|
|
450
|
+
initial_delay_frames: DEFAULT_INITIAL_RENDER_DELAY_FRAMES,
|
|
451
|
+
history_size: 60,
|
|
452
|
+
safety_multiplier: 2.0,
|
|
453
|
+
decay_per_sample_ms: 1.0,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
this.#scratch_send_buf = new BinaryBuffer();
|
|
457
|
+
this.#scratch_send_buf.setCapacity(SCRATCH_BUFFER_BYTES);
|
|
458
|
+
|
|
459
|
+
this.#scratch_interp_buf = new BinaryBuffer();
|
|
460
|
+
this.#scratch_interp_buf.setCapacity(SCRATCH_BUFFER_BYTES);
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @type {() => void}
|
|
464
|
+
*/
|
|
465
|
+
this.#bound_normalize_if_dirty = () => this.normalize_if_dirty();
|
|
466
|
+
|
|
467
|
+
this.#server_resume_grace_ms = server_resume_grace_ms;
|
|
468
|
+
this.#reconnect_policy = Object.freeze({ ...DEFAULT_RECONNECT_POLICY, ...(reconnect || {}) });
|
|
469
|
+
this.#transport_factory = transport_factory;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ----------------------------------------------------------------
|
|
473
|
+
// Configuration (must run before start())
|
|
474
|
+
// ----------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Register a component class for network replication.
|
|
478
|
+
*
|
|
479
|
+
* The component must have a {@link BinaryClassSerializationAdapter}
|
|
480
|
+
* registered for its `typeName` in the session's `binary_registry`
|
|
481
|
+
* before {@link start} is called — that adapter handles the wire
|
|
482
|
+
* format and rewind capture.
|
|
483
|
+
*
|
|
484
|
+
* `interpolator` is optional. When omitted, the component's state
|
|
485
|
+
* snaps on each received update (no sub-tick smoothing). When
|
|
486
|
+
* provided, it must extend {@link BinaryInterpolationAdapter} with
|
|
487
|
+
* `kind = InterpolationKind.Linear` — Discrete and Cubic are
|
|
488
|
+
* reserved for future support and currently throw.
|
|
489
|
+
*
|
|
490
|
+
* The order of `replicate()` calls must match across all peers in
|
|
491
|
+
* the session: AUTH_STATE payload layout iterates components in
|
|
492
|
+
* insertion order.
|
|
493
|
+
*
|
|
494
|
+
* @param {Function} ComponentClass
|
|
495
|
+
* @param {BinaryInterpolationAdapter} [interpolator]
|
|
496
|
+
*/
|
|
497
|
+
replicate(ComponentClass, interpolator = null) {
|
|
498
|
+
this.#assert_not_started('replicate');
|
|
499
|
+
assert.isFunction(ComponentClass, 'ComponentClass');
|
|
500
|
+
|
|
501
|
+
if (ComponentClass === NetworkIdentity) {
|
|
502
|
+
throw new Error("NetworkSession.replicate: NetworkIdentity is auto-replicated; do not call replicate() for it");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const entry of this.#user_replicated) {
|
|
506
|
+
if (entry.klass === ComponentClass) {
|
|
507
|
+
throw new Error(`NetworkSession.replicate: ${ComponentClass.typeName ?? ComponentClass.name} already registered`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (interpolator !== null) {
|
|
511
|
+
if (!(interpolator instanceof BinaryInterpolationAdapter)) {
|
|
512
|
+
throw new Error("NetworkSession.replicate: interpolator must extend BinaryInterpolationAdapter");
|
|
513
|
+
}
|
|
514
|
+
if (interpolator.kind !== InterpolationKind.Linear) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`NetworkSession.replicate: only Linear interpolation is currently supported; ` +
|
|
517
|
+
`got kind=${interpolator.kind} for ${ComponentClass.typeName ?? ComponentClass.name}. ` +
|
|
518
|
+
`Omit the interpolator argument for snap (Discrete) behavior; Cubic is reserved.`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
this.#user_replicated.push({ klass: ComponentClass, interp: interpolator });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Register a user-defined {@link SimAction} subclass with the
|
|
527
|
+
* session. The action's `type_id` is assigned by the underlying
|
|
528
|
+
* registry when {@link start} runs. Calling code constructs
|
|
529
|
+
* instances directly (`new MyAction(...)`) and dispatches via
|
|
530
|
+
* {@link send} or returns them from the input sampler.
|
|
531
|
+
*
|
|
532
|
+
* @param {Function} SimActionClass class extending SimAction
|
|
533
|
+
*/
|
|
534
|
+
defineAction(SimActionClass) {
|
|
535
|
+
this.#assert_not_started('defineAction');
|
|
536
|
+
assert.isFunction(SimActionClass, 'SimActionClass');
|
|
537
|
+
this.#user_action_classes.push(SimActionClass);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Install a per-tick input sampler (client role only). The sampler
|
|
542
|
+
* is called once per local sim tick at predict time and must return
|
|
543
|
+
* an array of {@link SimAction} instances representing the local
|
|
544
|
+
* peer's inputs for that frame. The session executes them locally
|
|
545
|
+
* (predict), records their serialized bytes for replay, and forwards
|
|
546
|
+
* them over the action stream.
|
|
547
|
+
*
|
|
548
|
+
* The sampler is NOT called during reconciliation replay — the
|
|
549
|
+
* recorded bytes are deserialized and re-executed instead, so the
|
|
550
|
+
* action set faithfully reproduces what the user originally
|
|
551
|
+
* sampled at that frame.
|
|
552
|
+
*
|
|
553
|
+
* Return `[]` (or `null`) for ticks with no inputs.
|
|
554
|
+
*
|
|
555
|
+
* @param {(frame: number) => Array<SimAction>|null} sampler_fn
|
|
556
|
+
*/
|
|
557
|
+
defineInputSampler(sampler_fn) {
|
|
558
|
+
this.#assert_not_started('defineInputSampler');
|
|
559
|
+
assert.isFunction(sampler_fn, 'sampler_fn');
|
|
560
|
+
|
|
561
|
+
if (this.role !== NetworkSessionRole.Client) {
|
|
562
|
+
throw new Error(`NetworkSession.defineInputSampler: only valid for role='${NetworkSessionRole.Client}', got '${this.role}'`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
this.#input_sampler = sampler_fn;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ----------------------------------------------------------------
|
|
569
|
+
// Lifecycle
|
|
570
|
+
// ----------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Wire the session to the engine. After this, the EntityObserver
|
|
574
|
+
* is active, the right orchestrator is constructed, and a
|
|
575
|
+
* {@link NetworkSystem} is attached. Subsequent {@link replicate}
|
|
576
|
+
* / {@link defineAction} / {@link defineInputSampler} calls throw.
|
|
577
|
+
*
|
|
578
|
+
* Pre-condition: every component class passed to {@link replicate}
|
|
579
|
+
* must already have a `BinaryClassSerializationAdapter` registered
|
|
580
|
+
* in the session's binary_registry.
|
|
581
|
+
*/
|
|
582
|
+
async start() {
|
|
583
|
+
if (this.#started) throw new Error("NetworkSession.start: already started");
|
|
584
|
+
|
|
585
|
+
// Auto-register the NetworkIdentity adapter so initial-sync can
|
|
586
|
+
// ship it across the wire. Allow caller-supplied registrations
|
|
587
|
+
// to win (game might have a custom one).
|
|
588
|
+
if (this.binary_registry.getAdapter(NetworkIdentity.typeName) === undefined) {
|
|
589
|
+
this.binary_registry.registerAdapter(new NetworkIdentitySerializationAdapter(), NetworkIdentity.typeName);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Finalize the replicated-components ordering: NetworkIdentity
|
|
593
|
+
// FIRST (so it gets type_id=0 and arrives as the first per-entity
|
|
594
|
+
// record in snapshots — receiver attaches NetworkIdentity first,
|
|
595
|
+
// which triggers slot-table allocation before any other component
|
|
596
|
+
// payload needs to look up `entity_for(network_id)`).
|
|
597
|
+
let next_id = 0;
|
|
598
|
+
const network_identity_scratch = new BinaryBuffer();
|
|
599
|
+
network_identity_scratch.setCapacity(SCRATCH_BUFFER_BYTES);
|
|
600
|
+
this.#replicated.set(NetworkIdentity, {
|
|
601
|
+
interp: null,
|
|
602
|
+
interp_type_id: next_id++,
|
|
603
|
+
});
|
|
604
|
+
this.#interp_type_id_to_class[0] = NetworkIdentity;
|
|
605
|
+
for (const { klass, interp } of this.#user_replicated) {
|
|
606
|
+
const interp_type_id = next_id++;
|
|
607
|
+
this.#replicated.set(klass, { interp, interp_type_id });
|
|
608
|
+
this.#interp_type_id_to_class[interp_type_id] = klass;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Verify each replicated component has a serialization adapter.
|
|
612
|
+
for (const ComponentClass of this.#replicated.keys()) {
|
|
613
|
+
const name = ComponentClass.typeName;
|
|
614
|
+
if (typeof name !== 'string') {
|
|
615
|
+
throw new Error(`NetworkSession.start: ${ComponentClass.name} has no static .typeName; cannot look up its adapter`);
|
|
616
|
+
}
|
|
617
|
+
if (this.binary_registry.getAdapter(name) === undefined) {
|
|
618
|
+
throw new Error(`NetworkSession.start: no BinaryClassSerializationAdapter registered for ${name}; register one in the binary_registry before calling start()`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Build the synthetic ReplaceComponentAction now so it's in the
|
|
623
|
+
// action_classes list when the orchestrator is constructed.
|
|
624
|
+
this.#ReplaceComponentAction = this.#make_replace_component_action_class();
|
|
625
|
+
|
|
626
|
+
const replicated_components = Array.from(this.#replicated.keys());
|
|
627
|
+
const action_classes = [...this.#user_action_classes, this.#ReplaceComponentAction];
|
|
628
|
+
|
|
629
|
+
const orchestrator_opts = {
|
|
630
|
+
world: this.world,
|
|
631
|
+
binary_registry: this.binary_registry,
|
|
632
|
+
replicated_components,
|
|
633
|
+
action_classes,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
if (this.role === NetworkSessionRole.Host) {
|
|
637
|
+
this.#server = new ServerAuthoritativeServer({
|
|
638
|
+
...orchestrator_opts,
|
|
639
|
+
simulation_delay_ticks: this.simulation_delay_ticks,
|
|
640
|
+
});
|
|
641
|
+
this.#peer = this.#server.peer;
|
|
642
|
+
this.#peer.replicator.scope_filter = this.#scope_filter || new OwnerAwareScope({
|
|
643
|
+
world: this.world,
|
|
644
|
+
slot_table: this.#server.slot_table,
|
|
645
|
+
identity_class: NetworkIdentity,
|
|
646
|
+
});
|
|
647
|
+
this.#server.onTickComplete.add((sim_frame) => this.#on_host_tick_complete(sim_frame));
|
|
648
|
+
} else {
|
|
649
|
+
// Client role: covers both "playing" (input sampler registered)
|
|
650
|
+
// and "spectator" (no sampler — sampler-driven onPredict
|
|
651
|
+
// returns no actions, so the predict-reconcile machinery
|
|
652
|
+
// exists but stays idle).
|
|
653
|
+
this.#client = new ServerAuthoritativeClient({
|
|
654
|
+
...orchestrator_opts,
|
|
655
|
+
time_dilation: this.#time_dilation,
|
|
656
|
+
});
|
|
657
|
+
this.#peer = this.#client.peer;
|
|
658
|
+
if (this.#scope_filter) {
|
|
659
|
+
this.#peer.replicator.scope_filter = this.#scope_filter;
|
|
660
|
+
}
|
|
661
|
+
this.#wire_client_predict_reconcile();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Install the canonical-form normalize hook on the executor.
|
|
665
|
+
// Anything that calls executor.execute — predict apply, action-
|
|
666
|
+
// stream apply, reconcile replay — first normalizes the world.
|
|
667
|
+
// Idempotent: cheap when `render_dirty` is false (one bool
|
|
668
|
+
// check), expensive (one adapter.deserialize per remote-owned
|
|
669
|
+
// component) only on the first execute after a render pass.
|
|
670
|
+
this.#peer.executor.before_execute = this.#bound_normalize_if_dirty;
|
|
671
|
+
|
|
672
|
+
// NetworkSystem handles slot_table allocation on NetworkIdentity attach.
|
|
673
|
+
// addSystem returns a promise that resolves once the system's own
|
|
674
|
+
// startup completes (it auto-starts when added to an already-Running
|
|
675
|
+
// EM). Await it so the session's identity observer below doesn't
|
|
676
|
+
// race with the NetworkSystem's observer over slot_table allocation.
|
|
677
|
+
await this.entity_manager.addSystem(new NetworkSystem(this.#peer));
|
|
678
|
+
|
|
679
|
+
// Sub-tick interpolation log: records per-frame replicated component
|
|
680
|
+
// state, sampled at render time for remotely-owned entities.
|
|
681
|
+
this.#interp_log = new InterpolationLog({
|
|
682
|
+
buffer_capacity_bytes: 65536,
|
|
683
|
+
records_capacity: 4096,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Per-frame applied: snapshot all replicated components for each
|
|
687
|
+
// remote-owned entity into the interpolation log.
|
|
688
|
+
this.#peer.replicator.onFrameApplied.add((peer_id, frame_number) => {
|
|
689
|
+
this.#on_frame_applied(peer_id, frame_number);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// Initial-sync arrival (client only): store the session token,
|
|
693
|
+
// populate the world from the host's snapshot, resume normal
|
|
694
|
+
// action-stream processing, and finalize any in-flight Tier-3
|
|
695
|
+
// resync state.
|
|
696
|
+
if (this.role === NetworkSessionRole.Client) {
|
|
697
|
+
this.#peer.onInitialSync.add((_peer_id, session_token, _frame_number, buf, payload_end) => {
|
|
698
|
+
const token = new UUID();
|
|
699
|
+
token.data = session_token;
|
|
700
|
+
this.session_token = token;
|
|
701
|
+
this.#apply_initial_sync(buf, payload_end);
|
|
702
|
+
if (this.#reconnect_state === ReconnectState.Resyncing) {
|
|
703
|
+
this.#enter_connected();
|
|
704
|
+
this.onReconnected.send1({ state_resynced: true });
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
// Host's response to our RESUME_HELLO during reconnect.
|
|
708
|
+
this.#peer.onResumeAccept.add(() => {
|
|
709
|
+
if (this.#reconnect_state === ReconnectState.Reconnecting
|
|
710
|
+
|| this.#reconnect_state === ReconnectState.Resyncing) {
|
|
711
|
+
this.#enter_connected();
|
|
712
|
+
this.onReconnected.send1({ state_resynced: false });
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
this.#peer.onResumeReject.add((_peer_id, reason_code) => {
|
|
716
|
+
if (this.#reconnect_policy.accept_state_resync) {
|
|
717
|
+
// Tier-3: drop unconfirmed predictions and wait for
|
|
718
|
+
// the host to deliver a fresh INITIAL_SYNC.
|
|
719
|
+
this.#sampled_actions_per_frame.clear();
|
|
720
|
+
this.session_token = null;
|
|
721
|
+
this.#reconnect_state = ReconnectState.Resyncing;
|
|
722
|
+
// Restart the elapsed-ms budget so the Resyncing
|
|
723
|
+
// watchdog (see #tick_reconnect) has a full
|
|
724
|
+
// total_timeout_ms before falling to Lost.
|
|
725
|
+
this.#reconnect_elapsed_ms = 0;
|
|
726
|
+
} else {
|
|
727
|
+
this.#permanently_lose_connection(`resume_rejected:${reason_code}`);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
// Host kicked us — final, no reconnect.
|
|
731
|
+
this.#peer.onDisconnectPacket.add((_peer_id, reason_label) => {
|
|
732
|
+
this.#permanently_lose_connection(reason_label || 'disconnected_by_host');
|
|
733
|
+
});
|
|
734
|
+
} else {
|
|
735
|
+
// Host: RESUME_HELLO validation + peer-initiated disconnects.
|
|
736
|
+
this.#peer.onResumeHello.add((peer_id, local_peer_id, last_acked_frame, session_token) => {
|
|
737
|
+
this.#on_resume_hello(peer_id, local_peer_id, last_acked_frame, session_token);
|
|
738
|
+
});
|
|
739
|
+
this.#peer.onDisconnectPacket.add((peer_id, reason_label) => {
|
|
740
|
+
// Client-initiated disconnect — skip the grace window
|
|
741
|
+
// and free state immediately.
|
|
742
|
+
this.drop_peer(peer_id, reason_label || 'client_disconnect');
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// EntityObserver for NetworkIdentity: patches owner/network_id on
|
|
747
|
+
// attach, wires the per-entity mutation listener.
|
|
748
|
+
this.#identity_observer = new EntityObserver(
|
|
749
|
+
[NetworkIdentity],
|
|
750
|
+
(identity, entity_id) => this.#on_identity_attached(identity, entity_id),
|
|
751
|
+
(identity, entity_id) => this.#on_identity_detached(identity, entity_id),
|
|
752
|
+
this,
|
|
753
|
+
);
|
|
754
|
+
this.#identity_observer.connect(this.world);
|
|
755
|
+
|
|
756
|
+
this.#started = true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Connect to a remote peer. Host can connect many peers (one per
|
|
761
|
+
* client); client typically connects one (the server). Called both
|
|
762
|
+
* for first-time connects and reconnect attempts — the session
|
|
763
|
+
* detects which based on whether a `session_token` is cached
|
|
764
|
+
* (client) or whether the peer is in the grace window (host).
|
|
765
|
+
*
|
|
766
|
+
* @param {number} remote_peer_id
|
|
767
|
+
* @param {object} transport
|
|
768
|
+
*/
|
|
769
|
+
connect(remote_peer_id, transport) {
|
|
770
|
+
if (!this.#started) throw new Error("NetworkSession.connect: call start() first");
|
|
771
|
+
|
|
772
|
+
if (this.role === NetworkSessionRole.Host) {
|
|
773
|
+
const in_grace = this.#peer_grace_state.has(remote_peer_id);
|
|
774
|
+
this.#server.connect_peer(remote_peer_id, transport);
|
|
775
|
+
if (in_grace) {
|
|
776
|
+
// Resume candidate. Wait for RESUME_HELLO; restore
|
|
777
|
+
// baseline + add to connected_peers on validation.
|
|
778
|
+
// Don't queue INITIAL_SYNC — it'll only fire if we
|
|
779
|
+
// RESUME_REJECT.
|
|
780
|
+
} else {
|
|
781
|
+
// Fresh peer. Queue initial-sync for the next host tick.
|
|
782
|
+
this.#pending_initial_sync.set(remote_peer_id, 2);
|
|
783
|
+
this.#connected_peers.add(remote_peer_id);
|
|
784
|
+
}
|
|
785
|
+
this.#wire_peer_transport_disconnect(remote_peer_id, transport);
|
|
786
|
+
} else {
|
|
787
|
+
// Client
|
|
788
|
+
this.#client.connect_to_server(remote_peer_id, transport);
|
|
789
|
+
this.#remote_peer_id = remote_peer_id;
|
|
790
|
+
this.#current_transport = transport;
|
|
791
|
+
this.#wire_client_transport_disconnect(transport);
|
|
792
|
+
this.#connected_peers.add(remote_peer_id);
|
|
793
|
+
|
|
794
|
+
// If we have a session token cached, treat this as a
|
|
795
|
+
// reconnect attempt and send RESUME_HELLO immediately. A
|
|
796
|
+
// fresh session has no token yet (it arrives in the first
|
|
797
|
+
// INITIAL_SYNC), so first-time connects don't trigger this.
|
|
798
|
+
if (this.session_token !== null) {
|
|
799
|
+
const last_acked = Math.max(0, this.#latest_received_frame);
|
|
800
|
+
this.#peer.send_resume_hello(
|
|
801
|
+
remote_peer_id, this.local_peer_id, last_acked,
|
|
802
|
+
this.session_token.data,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Host-only: forcibly free a peer's state, regardless of whether
|
|
810
|
+
* they are actively connected, in the grace window, or already
|
|
811
|
+
* unknown. Fires `onPeerPermanentlyDropped(peer_id, reason)`.
|
|
812
|
+
* Sends a DISCONNECT packet if the peer is still actively
|
|
813
|
+
* connected so the remote can short-circuit its reconnect attempts.
|
|
814
|
+
*
|
|
815
|
+
* Policy for *when* to drop (high server load, anti-cheat, admin
|
|
816
|
+
* kick, etc.) is the caller's; the session provides the mechanism.
|
|
817
|
+
*
|
|
818
|
+
* @param {number} peer_id
|
|
819
|
+
* @param {string} [reason]
|
|
820
|
+
*/
|
|
821
|
+
drop_peer(peer_id, reason = '') {
|
|
822
|
+
if (this.role !== NetworkSessionRole.Host) {
|
|
823
|
+
throw new Error("NetworkSession.drop_peer: host-only");
|
|
824
|
+
}
|
|
825
|
+
const was_connected = this.#connected_peers.has(peer_id);
|
|
826
|
+
const was_in_grace = this.#peer_grace_state.has(peer_id);
|
|
827
|
+
if (!was_connected && !was_in_grace) return;
|
|
828
|
+
|
|
829
|
+
if (was_connected) {
|
|
830
|
+
// Best-effort notification — peer may have already dropped.
|
|
831
|
+
try {
|
|
832
|
+
this.#peer.send_disconnect(peer_id, reason);
|
|
833
|
+
} catch (_) { /* swallow */
|
|
834
|
+
}
|
|
835
|
+
this.#server.disconnect_peer(peer_id);
|
|
836
|
+
}
|
|
837
|
+
this.#unwire_peer_transport_disconnect(peer_id);
|
|
838
|
+
this.#connected_peers.delete(peer_id);
|
|
839
|
+
this.#peer_grace_state.delete(peer_id);
|
|
840
|
+
this.#peer_session_tokens.delete(peer_id);
|
|
841
|
+
this.#pending_initial_sync.delete(peer_id);
|
|
842
|
+
this.onPeerPermanentlyDropped.send2(peer_id, reason);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Client-only: voluntarily disconnect from the server. Sends a
|
|
847
|
+
* DISCONNECT packet so the host can free state immediately and
|
|
848
|
+
* skip the grace window. Disables reconnect for this session.
|
|
849
|
+
*
|
|
850
|
+
* @param {string} [reason]
|
|
851
|
+
*/
|
|
852
|
+
disconnect(reason = '') {
|
|
853
|
+
if (this.role !== NetworkSessionRole.Client) {
|
|
854
|
+
throw new Error("NetworkSession.disconnect: client-only — host uses drop_peer instead");
|
|
855
|
+
}
|
|
856
|
+
if (this.#remote_peer_id < 0) return;
|
|
857
|
+
const peer_id = this.#remote_peer_id;
|
|
858
|
+
try {
|
|
859
|
+
this.#peer.send_disconnect(peer_id, reason);
|
|
860
|
+
} catch (_) { /* swallow */
|
|
861
|
+
}
|
|
862
|
+
// Tear down the channel binding so a subsequent connect() with a
|
|
863
|
+
// fresh transport doesn't trip the underlying NetworkPeer's
|
|
864
|
+
// "peer already exists" guard. Mirrors the cleanup that
|
|
865
|
+
// #on_client_transport_disconnected does on an involuntary drop.
|
|
866
|
+
try {
|
|
867
|
+
this.#peer.disconnect_peer(peer_id);
|
|
868
|
+
} catch (_) { /* swallow */
|
|
869
|
+
}
|
|
870
|
+
if (this.#current_transport_listener !== null && this.#current_transport !== null) {
|
|
871
|
+
try {
|
|
872
|
+
this.#current_transport.onDisconnect.remove(this.#current_transport_listener);
|
|
873
|
+
} catch (_) { /* swallow */
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
this.#current_transport = null;
|
|
877
|
+
this.#current_transport_listener = null;
|
|
878
|
+
this.#remote_peer_id = -1;
|
|
879
|
+
this.#permanently_lose_connection(reason || 'client_disconnect');
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Tear down. Idempotent — safe to call from cleanup paths.
|
|
884
|
+
*/
|
|
885
|
+
stop() {
|
|
886
|
+
if (!this.#started) return;
|
|
887
|
+
if (this.#identity_observer !== null) {
|
|
888
|
+
this.#identity_observer.disconnect();
|
|
889
|
+
this.#identity_observer = null;
|
|
890
|
+
}
|
|
891
|
+
for (const [entity_id, listener] of this.#event_listeners) {
|
|
892
|
+
this.world.removeEntityEventListener(entity_id, "net_mutate_component", listener);
|
|
893
|
+
}
|
|
894
|
+
this.#event_listeners.clear();
|
|
895
|
+
this.#remote_entities.clear();
|
|
896
|
+
this.#sampled_actions_per_frame.clear();
|
|
897
|
+
this.#started = false;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ----------------------------------------------------------------
|
|
901
|
+
// Per-tick driver
|
|
902
|
+
// ----------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Fixed-timestep accumulator-driven step. Catch-up bounded by
|
|
906
|
+
* `MAX_FIXED_STEPS_PER_TICK`. Under `role: 'client'` the local frame
|
|
907
|
+
* advances at `1 / dilation_factor` per fixed step.
|
|
908
|
+
*
|
|
909
|
+
* @param {number} dt_seconds elapsed wall seconds since the previous call
|
|
910
|
+
*/
|
|
911
|
+
tick(dt_seconds) {
|
|
912
|
+
if (!this.#started) return;
|
|
913
|
+
|
|
914
|
+
// Restore canonical form before sim work. The executor's
|
|
915
|
+
// `before_execute` hook also covers per-action paths (e.g.
|
|
916
|
+
// packets delivered between session.tick calls).
|
|
917
|
+
this.normalize_if_dirty();
|
|
918
|
+
|
|
919
|
+
const dt_ms = dt_seconds * 1000;
|
|
920
|
+
|
|
921
|
+
// Host: expire grace-window peers whose deadline has passed.
|
|
922
|
+
if (this.role === NetworkSessionRole.Host) {
|
|
923
|
+
this.#expire_grace_peers(performance.now());
|
|
924
|
+
} else {
|
|
925
|
+
// Client: drive the reconnect timer.
|
|
926
|
+
this.#tick_reconnect(dt_ms);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
this.#sim_accum_ms += dt_ms;
|
|
930
|
+
let steps = 0;
|
|
931
|
+
while (this.#sim_accum_ms >= this.tick_period_ms && steps < MAX_FIXED_STEPS_PER_TICK) {
|
|
932
|
+
this.#sim_accum_ms -= this.tick_period_ms;
|
|
933
|
+
this.#simulate_one_step();
|
|
934
|
+
steps++;
|
|
935
|
+
}
|
|
936
|
+
if (steps === MAX_FIXED_STEPS_PER_TICK) this.#sim_accum_ms = 0;
|
|
937
|
+
|
|
938
|
+
// Per-render-frame: smooth interpolated rendering of remote
|
|
939
|
+
// entities. Marks the live ECD as polluted so the next sim-tick
|
|
940
|
+
// or reconcile starts with a normalize.
|
|
941
|
+
this.#render_interpolated_entities();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Undo render-time interpolation on remote-owned components by
|
|
946
|
+
* restoring each from its latest InterpolationLog entry. Idempotent
|
|
947
|
+
* via the `render_dirty` flag — cheap when false.
|
|
948
|
+
*
|
|
949
|
+
* Called from `tick()` (top), `executor.before_execute` (per-action
|
|
950
|
+
* applies, including transport-delivered action stream), and
|
|
951
|
+
* `client.onBeforeReconcile` (before the rewind walk).
|
|
952
|
+
*
|
|
953
|
+
* @private
|
|
954
|
+
*/
|
|
955
|
+
normalize_if_dirty() {
|
|
956
|
+
if (!this.render_dirty) return;
|
|
957
|
+
this.render_dirty = false;
|
|
958
|
+
|
|
959
|
+
if (this.#latest_received_frame < 0) return;
|
|
960
|
+
if (this.#remote_entities.size === 0) return;
|
|
961
|
+
|
|
962
|
+
const scratch = this.#scratch_interp_buf;
|
|
963
|
+
const tick = this.#latest_received_frame;
|
|
964
|
+
|
|
965
|
+
for (const entity_id of this.#remote_entities) {
|
|
966
|
+
const identity = this.world.getComponent(entity_id, NetworkIdentity);
|
|
967
|
+
if (identity === undefined || identity.network_id < 0) continue;
|
|
968
|
+
for (const [ComponentClass, desc] of this.#replicated) {
|
|
969
|
+
if (desc.interp === null) continue;
|
|
970
|
+
const live = this.world.getComponent(entity_id, ComponentClass);
|
|
971
|
+
if (live === undefined) continue;
|
|
972
|
+
scratch.position = 0;
|
|
973
|
+
// tick_a === tick_b, t = 0 — degenerate "snap to single
|
|
974
|
+
// snapshot" lerp. Output equals the stored snapshot.
|
|
975
|
+
const ok = this.#interp_log.interpolate(
|
|
976
|
+
scratch, identity.network_id, desc.interp_type_id,
|
|
977
|
+
tick, tick, 0, desc.interp,
|
|
978
|
+
);
|
|
979
|
+
if (!ok) continue;
|
|
980
|
+
scratch.position = 0;
|
|
981
|
+
const adapter = this.binary_registry.getAdapter(ComponentClass.typeName);
|
|
982
|
+
adapter.deserialize(scratch, live);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Dispatch one user-defined action. Equivalent to
|
|
989
|
+
* `peer.executor.execute(action, this.local_peer_id)` but exposed
|
|
990
|
+
* at the session level so callers don't need a reference to the
|
|
991
|
+
* executor or to know the local peer id.
|
|
992
|
+
*
|
|
993
|
+
* @param {SimAction} action_instance
|
|
994
|
+
*/
|
|
995
|
+
send(action_instance) {
|
|
996
|
+
if (!this.#started) throw new Error("NetworkSession.send: call start() first");
|
|
997
|
+
if (!action_instance || action_instance.isSimAction !== true) {
|
|
998
|
+
throw new Error("NetworkSession.send: argument must be a SimAction instance");
|
|
999
|
+
}
|
|
1000
|
+
this.#peer.executor.execute(action_instance, this.local_peer_id);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ----------------------------------------------------------------
|
|
1004
|
+
// Accessors
|
|
1005
|
+
// ----------------------------------------------------------------
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* @returns {ServerAuthoritativeServer|null}
|
|
1009
|
+
*/
|
|
1010
|
+
get server() {
|
|
1011
|
+
return this.#server;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* @returns {ServerAuthoritativeClient|null}
|
|
1016
|
+
*/
|
|
1017
|
+
get client() {
|
|
1018
|
+
return this.#client;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* @returns {NetworkPeer|null}
|
|
1023
|
+
*/
|
|
1024
|
+
get peer() {
|
|
1025
|
+
return this.#peer;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* @returns {InterpolationLog|null}
|
|
1030
|
+
*/
|
|
1031
|
+
get interpolation_log() {
|
|
1032
|
+
return this.#interp_log;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* @returns {AdaptiveRenderDelay}
|
|
1037
|
+
*/
|
|
1038
|
+
get adaptive_render_delay() {
|
|
1039
|
+
return this.#adaptive_render_delay;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* @returns {TimeDilation}
|
|
1044
|
+
*/
|
|
1045
|
+
get time_dilation() {
|
|
1046
|
+
return this.#time_dilation;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Most recent locally-completed sim frame.
|
|
1051
|
+
*/
|
|
1052
|
+
get current_frame() {
|
|
1053
|
+
if (this.role === NetworkSessionRole.Host) return this.#server.current_sim_frame;
|
|
1054
|
+
return this.#local_frame - 1;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ----------------------------------------------------------------
|
|
1058
|
+
// Internal: identity observer (component-driven spawn/despawn)
|
|
1059
|
+
// ----------------------------------------------------------------
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Patch `NetworkIdentity` on attach: `owner_peer_id` defaults to
|
|
1063
|
+
* this session's `local_peer_id` if unset; `network_id` is assigned
|
|
1064
|
+
* by {@link NetworkSystem.link}, which fires on the same attach.
|
|
1065
|
+
*
|
|
1066
|
+
* Callers must not mutate either field afterwards — the slot table
|
|
1067
|
+
* and replicator cache them.
|
|
1068
|
+
*
|
|
1069
|
+
*/
|
|
1070
|
+
#on_identity_attached(identity, entity_id) {
|
|
1071
|
+
if (identity.owner_peer_id < 0) {
|
|
1072
|
+
identity.owner_peer_id = this.local_peer_id;
|
|
1073
|
+
}
|
|
1074
|
+
if (identity.owner_peer_id !== this.local_peer_id) {
|
|
1075
|
+
this.#remote_entities.add(entity_id);
|
|
1076
|
+
}
|
|
1077
|
+
// Per-entity event listener for net_mutate_component.
|
|
1078
|
+
const listener = (payload) => this.#on_net_mutate_component(entity_id, payload);
|
|
1079
|
+
this.world.addEntityEventListener(entity_id, "net_mutate_component", listener);
|
|
1080
|
+
this.#event_listeners.set(entity_id, listener);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
#on_identity_detached(_identity, entity_id) {
|
|
1084
|
+
const listener = this.#event_listeners.get(entity_id);
|
|
1085
|
+
if (listener !== undefined) {
|
|
1086
|
+
this.world.removeEntityEventListener(entity_id, "net_mutate_component", listener);
|
|
1087
|
+
this.#event_listeners.delete(entity_id);
|
|
1088
|
+
}
|
|
1089
|
+
this.#remote_entities.delete(entity_id);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Handler for `"net_mutate_component"` events. Translates the payload
|
|
1094
|
+
* into a `ReplaceComponentAction` and dispatches via the executor.
|
|
1095
|
+
*
|
|
1096
|
+
* Payload:
|
|
1097
|
+
* - `{ component_type, new_state }` — recommended; engine captures
|
|
1098
|
+
* current bytes as prior, then applies `new_state` via the
|
|
1099
|
+
* replication adapter.
|
|
1100
|
+
* - `{ component_type }` — caller has already mutated live; prior
|
|
1101
|
+
* bytes equal post bytes (rewind for this mutation is a no-op).
|
|
1102
|
+
*
|
|
1103
|
+
*/
|
|
1104
|
+
#on_net_mutate_component(entity_id, payload) {
|
|
1105
|
+
const { component_type, new_state } = payload || {};
|
|
1106
|
+
if (!component_type) {
|
|
1107
|
+
throw new Error("net_mutate_component: payload must include `component_type`");
|
|
1108
|
+
}
|
|
1109
|
+
const desc = this.#replicated.get(component_type);
|
|
1110
|
+
if (desc === undefined) {
|
|
1111
|
+
throw new Error(`net_mutate_component: ${component_type.typeName ?? component_type.name} is not replicated; call session.replicate() for it before mutating`);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const network_id = this.#peer.slot_table.network_for(entity_id);
|
|
1115
|
+
if (network_id < 0) return; // not networked yet
|
|
1116
|
+
|
|
1117
|
+
const live = this.world.getComponent(entity_id, component_type);
|
|
1118
|
+
if (live === undefined) return;
|
|
1119
|
+
|
|
1120
|
+
const adapter = this.binary_registry.getAdapter(component_type.typeName);
|
|
1121
|
+
|
|
1122
|
+
// Build the action with the new-state bytes pre-baked. The
|
|
1123
|
+
// executor will then capture the current `live` as prior bytes
|
|
1124
|
+
// before action.apply deserializes new bytes into `live`.
|
|
1125
|
+
const action = this.#peer.action_registry.acquire(this.#ReplaceComponentAction);
|
|
1126
|
+
action.network_id = network_id;
|
|
1127
|
+
action.component_type_id = desc.interp_type_id;
|
|
1128
|
+
action.payload_buf.position = 0;
|
|
1129
|
+
if (new_state !== undefined && new_state !== null) {
|
|
1130
|
+
adapter.serialize(action.payload_buf, new_state);
|
|
1131
|
+
} else {
|
|
1132
|
+
// No new_state: live IS the new state.
|
|
1133
|
+
adapter.serialize(action.payload_buf, live);
|
|
1134
|
+
}
|
|
1135
|
+
action.payload_length = action.payload_buf.position;
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
this.#peer.executor.execute(action, this.local_peer_id);
|
|
1139
|
+
} finally {
|
|
1140
|
+
this.#peer.action_registry.release(action);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Build the synthetic ReplaceComponentAction class.
|
|
1146
|
+
* The factory captures `interp_type_id_to_class` and `binary_registry` by
|
|
1147
|
+
* reference so the inner class methods can resolve type ids and
|
|
1148
|
+
* adapters without reaching into the outer class's private fields
|
|
1149
|
+
* (which `#`-private syntax would forbid from a different class).
|
|
1150
|
+
*/
|
|
1151
|
+
#make_replace_component_action_class() {
|
|
1152
|
+
const interp_type_id_to_class = this.#interp_type_id_to_class;
|
|
1153
|
+
const binary_registry = this.binary_registry;
|
|
1154
|
+
return class ReplaceComponentAction extends SimAction {
|
|
1155
|
+
constructor(network_id = 0, component_type_id = 0) {
|
|
1156
|
+
super();
|
|
1157
|
+
this.network_id = network_id;
|
|
1158
|
+
this.component_type_id = component_type_id;
|
|
1159
|
+
this.payload_buf = new BinaryBuffer();
|
|
1160
|
+
this.payload_buf.setCapacity(SCRATCH_BUFFER_BYTES);
|
|
1161
|
+
this.payload_length = 0;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
apply(world, executor) {
|
|
1165
|
+
const local = executor.slot_table.entity_for(this.network_id);
|
|
1166
|
+
if (local < 0) return;
|
|
1167
|
+
const ComponentClass = interp_type_id_to_class[this.component_type_id];
|
|
1168
|
+
if (ComponentClass === undefined) return;
|
|
1169
|
+
const live = world.getComponent(local, ComponentClass);
|
|
1170
|
+
if (live === undefined) return;
|
|
1171
|
+
const adapter = binary_registry.getAdapter(ComponentClass.typeName);
|
|
1172
|
+
if (adapter === undefined) return;
|
|
1173
|
+
this.payload_buf.position = 0;
|
|
1174
|
+
adapter.deserialize(this.payload_buf, live);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
affected_components(callback, executor) {
|
|
1178
|
+
const local = executor.slot_table.entity_for(this.network_id);
|
|
1179
|
+
if (local < 0) return;
|
|
1180
|
+
const ComponentClass = interp_type_id_to_class[this.component_type_id];
|
|
1181
|
+
if (ComponentClass === undefined) return;
|
|
1182
|
+
callback(local, ComponentClass);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
serialize(buffer) {
|
|
1186
|
+
buffer.writeUintVar(this.network_id);
|
|
1187
|
+
buffer.writeUint8(this.component_type_id);
|
|
1188
|
+
buffer.writeUint32(this.payload_length);
|
|
1189
|
+
if (this.payload_length > 0) {
|
|
1190
|
+
buffer.writeBytes(this.payload_buf.raw_bytes, 0, this.payload_length);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
deserialize(buffer) {
|
|
1195
|
+
this.network_id = buffer.readUintVar();
|
|
1196
|
+
this.component_type_id = buffer.readUint8();
|
|
1197
|
+
this.payload_length = buffer.readUint32();
|
|
1198
|
+
if (this.payload_buf.raw_bytes.length < this.payload_length) {
|
|
1199
|
+
this.payload_buf.setCapacity(this.payload_length);
|
|
1200
|
+
}
|
|
1201
|
+
if (this.payload_length > 0) {
|
|
1202
|
+
buffer.readBytes(this.payload_buf.raw_bytes, 0, this.payload_length);
|
|
1203
|
+
}
|
|
1204
|
+
this.payload_buf.position = 0;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
reset() {
|
|
1208
|
+
this.network_id = 0;
|
|
1209
|
+
this.component_type_id = 0;
|
|
1210
|
+
this.payload_length = 0;
|
|
1211
|
+
this.payload_buf.position = 0;
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ----------------------------------------------------------------
|
|
1217
|
+
// Internal: per-step simulation
|
|
1218
|
+
// ----------------------------------------------------------------
|
|
1219
|
+
|
|
1220
|
+
#simulate_one_step() {
|
|
1221
|
+
if (this.role === NetworkSessionRole.Host) {
|
|
1222
|
+
this.#server.tick(this.#local_frame);
|
|
1223
|
+
this.#local_frame++;
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
// Client. Dilated tick rate: each fixed step advances by
|
|
1227
|
+
// `1 / dilation_factor` (capped at MAX_CLIENT_DILATED_TICKS_PER_STEP).
|
|
1228
|
+
this.#local_frame_accum += 1.0 / this.#client.dilation_factor;
|
|
1229
|
+
let inner = 0;
|
|
1230
|
+
while (this.#local_frame_accum >= 1.0 && inner < MAX_CLIENT_DILATED_TICKS_PER_STEP) {
|
|
1231
|
+
this.#local_frame_accum -= 1.0;
|
|
1232
|
+
this.#client.tick(this.#local_frame);
|
|
1233
|
+
this.#send_empty_ack_packet();
|
|
1234
|
+
this.#local_frame++;
|
|
1235
|
+
inner++;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Empty action-stream packet to advance the channel's seq/ack
|
|
1241
|
+
* baseline on ticks with no outbound actions of our own.
|
|
1242
|
+
*/
|
|
1243
|
+
#send_empty_ack_packet() {
|
|
1244
|
+
for (const peer_id of this.#connected_peers) {
|
|
1245
|
+
this.#peer.channel_for(peer_id).send(new Uint8Array(0), 0);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// ----------------------------------------------------------------
|
|
1250
|
+
// Internal: host-side tick completion (AUTH_STATE + dilation feedback)
|
|
1251
|
+
// ----------------------------------------------------------------
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
*
|
|
1255
|
+
* @param {number} sim_frame
|
|
1256
|
+
*/
|
|
1257
|
+
#on_host_tick_complete(sim_frame) {
|
|
1258
|
+
// Initial-sync first, so receivers have a populated world before
|
|
1259
|
+
// AUTH_STATE / action-stream packets land.
|
|
1260
|
+
if (this.#pending_initial_sync.size > 0) {
|
|
1261
|
+
|
|
1262
|
+
for (const [peer_id, sends_remaining] of this.#pending_initial_sync) {
|
|
1263
|
+
|
|
1264
|
+
this.#send_initial_sync_to(peer_id, sim_frame);
|
|
1265
|
+
|
|
1266
|
+
if (sends_remaining <= 1) {
|
|
1267
|
+
this.#pending_initial_sync.delete(peer_id);
|
|
1268
|
+
} else {
|
|
1269
|
+
this.#pending_initial_sync.set(peer_id, sends_remaining - 1);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// AUTH_STATE per owned entity, then time-dilation feedback, to
|
|
1277
|
+
// every connected peer.
|
|
1278
|
+
for (const peer_id of this.#connected_peers) {
|
|
1279
|
+
|
|
1280
|
+
for (const entity_id of this.#event_listeners.keys()) {
|
|
1281
|
+
|
|
1282
|
+
const identity = this.world.getComponent(entity_id, NetworkIdentity);
|
|
1283
|
+
|
|
1284
|
+
if (identity === undefined) {
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (identity.owner_peer_id !== peer_id) {
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
this.#send_auth_state_for_entity(peer_id, sim_frame, entity_id, identity);
|
|
1292
|
+
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
this.#server.send_time_dilation_feedback(peer_id);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Emit a Snapshotter-format payload to a freshly-connected peer.
|
|
1302
|
+
* Generates and stashes a UUID v1 session token on first send,
|
|
1303
|
+
* which the receiver echoes back via RESUME_HELLO to authenticate
|
|
1304
|
+
* later reconnects.
|
|
1305
|
+
* @param {number} peer_id
|
|
1306
|
+
* @param {number} sim_frame
|
|
1307
|
+
*/
|
|
1308
|
+
#send_initial_sync_to(peer_id, sim_frame) {
|
|
1309
|
+
let token = this.#peer_session_tokens.get(peer_id);
|
|
1310
|
+
|
|
1311
|
+
if (token === undefined) {
|
|
1312
|
+
token = UUID.v1();
|
|
1313
|
+
this.#peer_session_tokens.set(peer_id, token);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
this.#peer.send_initial_sync(peer_id, token.data, sim_frame, (buf) => {
|
|
1317
|
+
snapshotter_emit({
|
|
1318
|
+
buffer: buf,
|
|
1319
|
+
world: this.world,
|
|
1320
|
+
slot_table: this.#peer.slot_table,
|
|
1321
|
+
component_registry: this.#peer.component_registry,
|
|
1322
|
+
entity_iter: (cb) => {
|
|
1323
|
+
for (const entity_id of this.#event_listeners.keys()) {
|
|
1324
|
+
cb(entity_id);
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Apply an inbound INITIAL_SYNC payload. Walks the wire format
|
|
1333
|
+
* directly instead of using `snapshotter_apply` so we can:
|
|
1334
|
+
* - Handle re-sync (slot already allocated) without
|
|
1335
|
+
* `slot_table.allocate_at` throwing.
|
|
1336
|
+
* - Let `NetworkSystem.link` handle slot allocation on the
|
|
1337
|
+
* NetworkIdentity-attach side-effect (NetworkIdentity is always
|
|
1338
|
+
* first in the stream).
|
|
1339
|
+
*
|
|
1340
|
+
*/
|
|
1341
|
+
#apply_initial_sync(buffer, payload_end) {
|
|
1342
|
+
const entity_count = buffer.readUintVar();
|
|
1343
|
+
for (let i = 0; i < entity_count; i++) {
|
|
1344
|
+
const network_id = buffer.readUintVar();
|
|
1345
|
+
const component_count = buffer.readUint8();
|
|
1346
|
+
let local_entity_id = this.#peer.slot_table.entity_for(network_id);
|
|
1347
|
+
if (local_entity_id < 0) {
|
|
1348
|
+
local_entity_id = this.world.createEntity();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
for (let j = 0; j < component_count; j++) {
|
|
1352
|
+
const type_id = buffer.readUint8();
|
|
1353
|
+
const payload_len = buffer.readUint32();
|
|
1354
|
+
const ComponentClass = this.#peer.component_registry.class_of(type_id);
|
|
1355
|
+
const adapter = this.#peer.component_registry.adapter_for_id(type_id);
|
|
1356
|
+
if (ComponentClass === undefined || adapter === undefined) {
|
|
1357
|
+
buffer.position += payload_len;
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const existing = this.world.getComponent(local_entity_id, ComponentClass);
|
|
1361
|
+
if (existing === undefined) {
|
|
1362
|
+
const component = new ComponentClass();
|
|
1363
|
+
adapter.deserialize(buffer, component);
|
|
1364
|
+
this.world.addComponentToEntity(local_entity_id, component);
|
|
1365
|
+
} else {
|
|
1366
|
+
adapter.deserialize(buffer, existing);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (buffer.position > payload_end) {
|
|
1371
|
+
throw new Error(`NetworkSession.#apply_initial_sync: read past payload_end at network_id ${network_id}`);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
*
|
|
1378
|
+
* @param {number} peer_id
|
|
1379
|
+
* @param {number} sim_frame
|
|
1380
|
+
* @param {number} entity_id
|
|
1381
|
+
* @param {NetworkIdentity} identity
|
|
1382
|
+
*/
|
|
1383
|
+
#send_auth_state_for_entity(peer_id, sim_frame, entity_id, identity) {
|
|
1384
|
+
const network_id = identity.network_id;
|
|
1385
|
+
|
|
1386
|
+
if (network_id < 0) {
|
|
1387
|
+
// not initialized
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
this.#peer.send_auth_state(peer_id, sim_frame, network_id, (buf) => {
|
|
1392
|
+
|
|
1393
|
+
// Concatenate all replicated components in #replicated order.
|
|
1394
|
+
for (const [ComponentClass, desc] of this.#replicated) {
|
|
1395
|
+
|
|
1396
|
+
const live = this.world.getComponent(entity_id, ComponentClass);
|
|
1397
|
+
|
|
1398
|
+
if (live === undefined) {
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const adapter = this.binary_registry.getAdapter(ComponentClass.typeName);
|
|
1403
|
+
|
|
1404
|
+
adapter.serialize(buf, live);
|
|
1405
|
+
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// ----------------------------------------------------------------
|
|
1412
|
+
// Internal: host-side reconnect / grace / drop machinery
|
|
1413
|
+
// ----------------------------------------------------------------
|
|
1414
|
+
|
|
1415
|
+
#wire_peer_transport_disconnect(peer_id, transport) {
|
|
1416
|
+
if (!transport || !transport.onDisconnect) return;
|
|
1417
|
+
const listener = (reason) => this.#on_peer_transport_disconnected(peer_id, reason);
|
|
1418
|
+
transport.onDisconnect.add(listener);
|
|
1419
|
+
this.#peer_transport_listeners.set(peer_id, { transport, listener });
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
#unwire_peer_transport_disconnect(peer_id) {
|
|
1423
|
+
const entry = this.#peer_transport_listeners.get(peer_id);
|
|
1424
|
+
if (entry === undefined) return;
|
|
1425
|
+
try {
|
|
1426
|
+
entry.transport.onDisconnect.remove(entry.listener);
|
|
1427
|
+
} catch (_) { /* swallow */
|
|
1428
|
+
}
|
|
1429
|
+
this.#peer_transport_listeners.delete(peer_id);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Move a peer into the grace window after its transport reports a
|
|
1434
|
+
* disconnect. The peer's channel + baseline are torn down (so a
|
|
1435
|
+
* subsequent `connect()` on a fresh transport doesn't collide), but
|
|
1436
|
+
* the session token, slot-table entries, and `last_acked` watermark
|
|
1437
|
+
* are preserved for the grace window. On expiry, `#expire_grace_peers`
|
|
1438
|
+
* promotes the peer to fully-dropped.
|
|
1439
|
+
*/
|
|
1440
|
+
#on_peer_transport_disconnected(peer_id, reason) {
|
|
1441
|
+
if (!this.#connected_peers.has(peer_id)) return;
|
|
1442
|
+
const last_acked = this.#peer.baseline.last_acked(peer_id);
|
|
1443
|
+
this.#server.disconnect_peer(peer_id);
|
|
1444
|
+
this.#connected_peers.delete(peer_id);
|
|
1445
|
+
this.#unwire_peer_transport_disconnect(peer_id);
|
|
1446
|
+
this.#peer_grace_state.set(peer_id, {
|
|
1447
|
+
deadline_ms: performance.now() + this.#server_resume_grace_ms,
|
|
1448
|
+
last_acked,
|
|
1449
|
+
});
|
|
1450
|
+
this.onPeerLost.send2(peer_id, reason);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Validate a RESUME_HELLO and either welcome the peer back (restore
|
|
1455
|
+
* baseline + send RESUME_ACCEPT) or reject with a reason code.
|
|
1456
|
+
* Rejection schedules a fresh INITIAL_SYNC so the client falls
|
|
1457
|
+
* through to Tier-3.
|
|
1458
|
+
*/
|
|
1459
|
+
#on_resume_hello(transport_peer_id, claimed_peer_id, last_acked_frame, claimed_token_bytes) {
|
|
1460
|
+
// Engine convention: the matchmaking layer routes the reconnecting
|
|
1461
|
+
// transport to the same `peer_id` it had before. If `claimed_peer_id`
|
|
1462
|
+
// disagrees with `transport_peer_id`, the matchmaker mis-routed —
|
|
1463
|
+
// treat as collision and kick the connection cleanly. Symmetric
|
|
1464
|
+
// with the TokenMismatch path below: we MUST NOT leave the rogue
|
|
1465
|
+
// transport wired to the dispatcher (any subsequent packet would
|
|
1466
|
+
// still be routed), and we MUST NOT add them to the connected
|
|
1467
|
+
// set or queue an INITIAL_SYNC.
|
|
1468
|
+
if (transport_peer_id !== claimed_peer_id) {
|
|
1469
|
+
this.#peer.send_resume_reject(transport_peer_id, ResumeRejectReason.PeerIdCollision);
|
|
1470
|
+
this.#peer.send_disconnect(transport_peer_id, 'peer_id_collision');
|
|
1471
|
+
this.#server.disconnect_peer(transport_peer_id);
|
|
1472
|
+
this.#unwire_peer_transport_disconnect(transport_peer_id);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const grace = this.#peer_grace_state.get(claimed_peer_id);
|
|
1476
|
+
if (grace === undefined) {
|
|
1477
|
+
// Either the peer is fully unknown OR they're already
|
|
1478
|
+
// connected (their previous transport never dropped).
|
|
1479
|
+
// Either way, no resume record to validate against.
|
|
1480
|
+
this.#peer.send_resume_reject(transport_peer_id, ResumeRejectReason.UnknownPeer);
|
|
1481
|
+
// Treat as a fresh peer; schedule INITIAL_SYNC.
|
|
1482
|
+
this.#pending_initial_sync.set(transport_peer_id, 2);
|
|
1483
|
+
this.#connected_peers.add(transport_peer_id);
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
const expected_token = this.#peer_session_tokens.get(claimed_peer_id);
|
|
1487
|
+
if (expected_token === undefined) {
|
|
1488
|
+
this.#peer.send_resume_reject(transport_peer_id, ResumeRejectReason.UnknownPeer);
|
|
1489
|
+
this.#peer_grace_state.delete(claimed_peer_id);
|
|
1490
|
+
this.#pending_initial_sync.set(transport_peer_id, 2);
|
|
1491
|
+
this.#connected_peers.add(transport_peer_id);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const claimed = new UUID();
|
|
1495
|
+
claimed.data = claimed_token_bytes;
|
|
1496
|
+
if (!claimed.equals(expected_token)) {
|
|
1497
|
+
// Hard reject: a wrong-token attempt MUST NOT take over the
|
|
1498
|
+
// legitimate peer's grace state (would let an attacker
|
|
1499
|
+
// pre-empt the real owner's reconnect window) and MUST NOT
|
|
1500
|
+
// be fresh-treated either (would expose the world via
|
|
1501
|
+
// INITIAL_SYNC). Kick the connection; legitimate-but-
|
|
1502
|
+
// corrupted-token clients fall to permanent-lost and need
|
|
1503
|
+
// a new peer_id from the matchmaking layer.
|
|
1504
|
+
this.#peer.send_resume_reject(transport_peer_id, ResumeRejectReason.TokenMismatch);
|
|
1505
|
+
this.#peer.send_disconnect(transport_peer_id, 'token_mismatch');
|
|
1506
|
+
this.#server.disconnect_peer(transport_peer_id);
|
|
1507
|
+
this.#unwire_peer_transport_disconnect(transport_peer_id);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
// Valid resume. Restore the action-stream baseline so the
|
|
1511
|
+
// replicator packs from `last_acked + 1`, then accept.
|
|
1512
|
+
this.#peer.baseline.set_acked(claimed_peer_id, last_acked_frame);
|
|
1513
|
+
this.#peer_grace_state.delete(claimed_peer_id);
|
|
1514
|
+
this.#connected_peers.add(transport_peer_id);
|
|
1515
|
+
this.#peer.send_resume_accept(transport_peer_id);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Walk the grace map and promote any peer whose deadline has
|
|
1520
|
+
* passed to fully-dropped (fires `onPeerPermanentlyDropped`).
|
|
1521
|
+
* Called from `tick()`.
|
|
1522
|
+
*/
|
|
1523
|
+
#expire_grace_peers(now_ms) {
|
|
1524
|
+
if (this.#peer_grace_state.size === 0) return;
|
|
1525
|
+
for (const [peer_id, state] of this.#peer_grace_state) {
|
|
1526
|
+
if (state.deadline_ms <= now_ms) {
|
|
1527
|
+
this.#peer_grace_state.delete(peer_id);
|
|
1528
|
+
this.#peer_session_tokens.delete(peer_id);
|
|
1529
|
+
this.onPeerPermanentlyDropped.send2(peer_id, 'grace_expired');
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// ----------------------------------------------------------------
|
|
1535
|
+
// Internal: client-side reconnect state machine
|
|
1536
|
+
// ----------------------------------------------------------------
|
|
1537
|
+
|
|
1538
|
+
#wire_client_transport_disconnect(transport) {
|
|
1539
|
+
if (!transport || !transport.onDisconnect) return;
|
|
1540
|
+
if (this.#current_transport_listener !== null && this.#current_transport !== null) {
|
|
1541
|
+
try {
|
|
1542
|
+
this.#current_transport.onDisconnect.remove(this.#current_transport_listener);
|
|
1543
|
+
} catch (_) { /* swallow */
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const listener = (reason) => this.#on_client_transport_disconnected(reason);
|
|
1547
|
+
transport.onDisconnect.add(listener);
|
|
1548
|
+
this.#current_transport_listener = listener;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
#on_client_transport_disconnected(reason) {
|
|
1552
|
+
if (this.#reconnect_state === ReconnectState.Lost) return;
|
|
1553
|
+
// Tear down the dead peer/channel binding so a fresh transport
|
|
1554
|
+
// can be wired via `connect()`. The InterpolationLog,
|
|
1555
|
+
// session_token, predicted-action ledger, and live world all
|
|
1556
|
+
// survive — that's what we need to claim continuity on resume.
|
|
1557
|
+
if (this.#remote_peer_id >= 0) {
|
|
1558
|
+
try {
|
|
1559
|
+
this.#peer.disconnect_peer(this.#remote_peer_id);
|
|
1560
|
+
} catch (_) { /* swallow */
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
this.#current_transport = null;
|
|
1564
|
+
this.#current_transport_listener = null;
|
|
1565
|
+
if (!this.#reconnect_policy.enabled) {
|
|
1566
|
+
this.#permanently_lose_connection(reason || 'transport_disconnect');
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
this.#reconnect_state = ReconnectState.Reconnecting;
|
|
1570
|
+
this.#reconnect_attempt = 0;
|
|
1571
|
+
this.#reconnect_elapsed_ms = 0;
|
|
1572
|
+
this.#reconnect_next_delay_ms = this.#reconnect_policy.base_delay_ms;
|
|
1573
|
+
this.#reconnect_timer_remaining_ms = this.#reconnect_next_delay_ms;
|
|
1574
|
+
this.onConnectionLost.send1(reason || 'transport_disconnect');
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Per-tick advance for the client reconnect timer. When the timer
|
|
1579
|
+
* elapses we rebuild the transport via `transport_factory` and
|
|
1580
|
+
* call `connect()` on it; with a cached `session_token`, `connect`
|
|
1581
|
+
* sends RESUME_HELLO. The server replies with RESUME_ACCEPT
|
|
1582
|
+
* (→ Connected) or RESUME_REJECT (→ Resyncing, INITIAL_SYNC
|
|
1583
|
+
* pending) or nothing within the next back-off interval (→ retry).
|
|
1584
|
+
*/
|
|
1585
|
+
#tick_reconnect(dt_ms) {
|
|
1586
|
+
// Resyncing has its own deadline: after RESUME_REJECT we expect
|
|
1587
|
+
// the host to ship a fresh INITIAL_SYNC. If it never arrives
|
|
1588
|
+
// (host crashed, network silently broke), fall to Lost so the
|
|
1589
|
+
// application sees the failure instead of hanging forever.
|
|
1590
|
+
if (this.#reconnect_state === ReconnectState.Resyncing) {
|
|
1591
|
+
this.#reconnect_elapsed_ms += dt_ms;
|
|
1592
|
+
if (this.#reconnect_elapsed_ms >= this.#reconnect_policy.total_timeout_ms) {
|
|
1593
|
+
this.#permanently_lose_connection('resync_timeout');
|
|
1594
|
+
}
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
if (this.#reconnect_state !== ReconnectState.Reconnecting) return;
|
|
1598
|
+
this.#reconnect_elapsed_ms += dt_ms;
|
|
1599
|
+
if (this.#reconnect_elapsed_ms >= this.#reconnect_policy.total_timeout_ms) {
|
|
1600
|
+
this.#permanently_lose_connection('reconnect_timeout');
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
this.#reconnect_timer_remaining_ms -= dt_ms;
|
|
1604
|
+
if (this.#reconnect_timer_remaining_ms > 0) return;
|
|
1605
|
+
|
|
1606
|
+
// Time to attempt.
|
|
1607
|
+
this.#reconnect_attempt++;
|
|
1608
|
+
if (this.#reconnect_attempt > this.#reconnect_policy.max_attempts) {
|
|
1609
|
+
this.#permanently_lose_connection('reconnect_attempts_exhausted');
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
this.onReconnectAttempt.send1(this.#reconnect_attempt);
|
|
1613
|
+
|
|
1614
|
+
if (this.#transport_factory === null) {
|
|
1615
|
+
// No factory: caller must drive reconnects themselves. We
|
|
1616
|
+
// remain in Reconnecting until they call connect() with a
|
|
1617
|
+
// new transport, or until total_timeout_ms expires.
|
|
1618
|
+
this.#reconnect_timer_remaining_ms = this.#next_backoff_delay();
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
let new_transport;
|
|
1622
|
+
try {
|
|
1623
|
+
new_transport = this.#transport_factory();
|
|
1624
|
+
} catch (e) {
|
|
1625
|
+
console.warn('NetworkSession: transport_factory threw', e);
|
|
1626
|
+
this.#reconnect_timer_remaining_ms = this.#next_backoff_delay();
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
try {
|
|
1630
|
+
this.connect(this.#remote_peer_id, new_transport);
|
|
1631
|
+
} catch (e) {
|
|
1632
|
+
console.warn('NetworkSession: connect() threw during reconnect', e);
|
|
1633
|
+
this.#reconnect_timer_remaining_ms = this.#next_backoff_delay();
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
// Wait the back-off interval before retrying again (if RESUME_ACCEPT
|
|
1637
|
+
// never arrives in time).
|
|
1638
|
+
this.#reconnect_timer_remaining_ms = this.#next_backoff_delay();
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
#next_backoff_delay() {
|
|
1642
|
+
const next = this.#reconnect_next_delay_ms * this.#reconnect_policy.exponential_factor;
|
|
1643
|
+
this.#reconnect_next_delay_ms = Math.min(next, this.#reconnect_policy.max_delay_ms);
|
|
1644
|
+
return this.#reconnect_next_delay_ms;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
#enter_connected() {
|
|
1648
|
+
this.#reconnect_state = ReconnectState.Connected;
|
|
1649
|
+
this.#reconnect_attempt = 0;
|
|
1650
|
+
this.#reconnect_elapsed_ms = 0;
|
|
1651
|
+
// Reset the exponential back-off so a subsequent disconnect starts
|
|
1652
|
+
// its retry ladder from base_delay_ms — without this, the next drop
|
|
1653
|
+
// would inherit the last cycle's (potentially max_delay_ms) delay.
|
|
1654
|
+
this.#reconnect_next_delay_ms = this.#reconnect_policy.base_delay_ms;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
#permanently_lose_connection(reason) {
|
|
1658
|
+
if (this.#reconnect_state === ReconnectState.Lost) return;
|
|
1659
|
+
this.#reconnect_state = ReconnectState.Lost;
|
|
1660
|
+
this.onConnectionPermanentlyLost.send1(reason);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// ----------------------------------------------------------------
|
|
1664
|
+
// Internal: client predict-reconcile wiring
|
|
1665
|
+
// ----------------------------------------------------------------
|
|
1666
|
+
|
|
1667
|
+
#wire_client_predict_reconcile() {
|
|
1668
|
+
// Rewind doesn't go through executor.execute, so the
|
|
1669
|
+
// before_execute normalize hook misses it. Catch it here.
|
|
1670
|
+
this.#client.onBeforeReconcile.add(() => this.normalize_if_dirty());
|
|
1671
|
+
|
|
1672
|
+
// Predict: run the sampler, execute each action, record its
|
|
1673
|
+
// bytes so the replay path can re-execute without re-sampling.
|
|
1674
|
+
this.#client.onPredict.add((frame, _input_writer) => {
|
|
1675
|
+
if (this.#input_sampler === null) return;
|
|
1676
|
+
const actions = this.#input_sampler(frame);
|
|
1677
|
+
if (!actions) return;
|
|
1678
|
+
const recorded = [];
|
|
1679
|
+
for (const action of actions) {
|
|
1680
|
+
if (!action || action.isSimAction !== true) continue;
|
|
1681
|
+
const type_id = action.constructor.type_id;
|
|
1682
|
+
const scratch = this.#scratch_send_buf;
|
|
1683
|
+
scratch.position = 0;
|
|
1684
|
+
action.serialize(scratch);
|
|
1685
|
+
const bytes = scratch.raw_bytes.slice(0, scratch.position);
|
|
1686
|
+
recorded.push({ type_id, bytes });
|
|
1687
|
+
this.#client.executor.execute(action, this.local_peer_id);
|
|
1688
|
+
}
|
|
1689
|
+
this.#sampled_actions_per_frame.set(frame, recorded);
|
|
1690
|
+
this.#trim_sampled_action_ledger();
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
// Replay: re-execute recorded actions from the ledger. Sampler is
|
|
1694
|
+
// NOT called again — live input has moved on.
|
|
1695
|
+
this.#client.onReplay.add((frame, _input_reader) => {
|
|
1696
|
+
const recorded = this.#sampled_actions_per_frame.get(frame);
|
|
1697
|
+
if (!recorded) return;
|
|
1698
|
+
for (const { type_id, bytes } of recorded) {
|
|
1699
|
+
const klass = this.#peer.action_registry.klass_for(type_id);
|
|
1700
|
+
if (klass === undefined) continue;
|
|
1701
|
+
const action = this.#peer.action_registry.acquire(klass);
|
|
1702
|
+
const scratch = this.#scratch_interp_buf;
|
|
1703
|
+
scratch.position = 0;
|
|
1704
|
+
scratch.writeBytes(bytes, 0, bytes.length);
|
|
1705
|
+
scratch.position = 0;
|
|
1706
|
+
action.deserialize(scratch);
|
|
1707
|
+
try {
|
|
1708
|
+
this.#client.executor.execute(action, this.local_peer_id);
|
|
1709
|
+
} finally {
|
|
1710
|
+
this.#peer.action_registry.release(action);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
// Apply auth state: deserialize concatenated replicated-component
|
|
1716
|
+
// bytes into the live entity. Mirrors the host's serialize order.
|
|
1717
|
+
this.#client.onApplyAuthState.add((_server_frame, network_id, buffer) => {
|
|
1718
|
+
const local = this.#client.slot_table.entity_for(network_id);
|
|
1719
|
+
if (local < 0) return;
|
|
1720
|
+
for (const [ComponentClass] of this.#replicated) {
|
|
1721
|
+
const live = this.world.getComponent(local, ComponentClass);
|
|
1722
|
+
if (live === undefined) continue;
|
|
1723
|
+
const adapter = this.binary_registry.getAdapter(ComponentClass.typeName);
|
|
1724
|
+
adapter.deserialize(buffer, live);
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
#trim_sampled_action_ledger() {
|
|
1730
|
+
const action_log = this.#peer.action_log;
|
|
1731
|
+
if (action_log === undefined) return;
|
|
1732
|
+
const oldest_keep = Math.max(0, this.#local_frame - action_log.frame_capacity);
|
|
1733
|
+
for (const f of this.#sampled_actions_per_frame.keys()) {
|
|
1734
|
+
if (f < oldest_keep) this.#sampled_actions_per_frame.delete(f);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// ----------------------------------------------------------------
|
|
1739
|
+
// Internal: interpolation log + render-time sampling
|
|
1740
|
+
// ----------------------------------------------------------------
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Record post-apply state for every remote-owned entity into the
|
|
1744
|
+
* interpolation log. Locally-owned entities are predicted, not
|
|
1745
|
+
* interpolated, so they're skipped.
|
|
1746
|
+
*
|
|
1747
|
+
* Doesn't fire on the host — the replicator's deferral path doesn't
|
|
1748
|
+
* emit `onFrameApplied`, so the host gets no recording naturally.
|
|
1749
|
+
*/
|
|
1750
|
+
#on_frame_applied(_peer_id, frame_number) {
|
|
1751
|
+
this.#interp_log.begin_tick(frame_number);
|
|
1752
|
+
for (const entity_id of this.#remote_entities) {
|
|
1753
|
+
const identity = this.world.getComponent(entity_id, NetworkIdentity);
|
|
1754
|
+
if (identity === undefined || identity.network_id < 0) continue;
|
|
1755
|
+
for (const [ComponentClass, desc] of this.#replicated) {
|
|
1756
|
+
if (desc.interp === null) continue;
|
|
1757
|
+
const live = this.world.getComponent(entity_id, ComponentClass);
|
|
1758
|
+
if (live === undefined) continue;
|
|
1759
|
+
const adapter = this.binary_registry.getAdapter(ComponentClass.typeName);
|
|
1760
|
+
const rec_buf = this.#interp_log.begin_record(identity.network_id, desc.interp_type_id);
|
|
1761
|
+
adapter.serialize(rec_buf, live);
|
|
1762
|
+
this.#interp_log.end_record();
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
this.#interp_log.end_tick();
|
|
1766
|
+
|
|
1767
|
+
if (frame_number > this.#latest_received_frame) {
|
|
1768
|
+
this.#adaptive_render_delay.record_arrival(performance.now(), frame_number);
|
|
1769
|
+
this.#latest_received_frame = frame_number;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Advance the smooth_render_frame state machine and write
|
|
1775
|
+
* interpolated bytes into each remote-owned entity's live
|
|
1776
|
+
* components. Sets `render_dirty` if anything was written.
|
|
1777
|
+
*
|
|
1778
|
+
* Host early-returns naturally (no `#latest_received_frame`
|
|
1779
|
+
* recorded — deferred-apply path doesn't fire `onFrameApplied`).
|
|
1780
|
+
*/
|
|
1781
|
+
#render_interpolated_entities() {
|
|
1782
|
+
if (this.#latest_received_frame < 0) return;
|
|
1783
|
+
if (this.#remote_entities.size === 0) return;
|
|
1784
|
+
|
|
1785
|
+
const now_ms = performance.now();
|
|
1786
|
+
const render_delay_frames = this.#adaptive_render_delay.delay_ms() / this.tick_period_ms;
|
|
1787
|
+
const target = this.#latest_received_frame - render_delay_frames;
|
|
1788
|
+
|
|
1789
|
+
if (this.#smooth_render_frame < 0) {
|
|
1790
|
+
this.#smooth_render_frame = target < 0 ? 0 : target;
|
|
1791
|
+
this.#smooth_last_wall_ms = now_ms;
|
|
1792
|
+
} else {
|
|
1793
|
+
const dt_ms = now_ms - this.#smooth_last_wall_ms;
|
|
1794
|
+
this.#smooth_last_wall_ms = now_ms;
|
|
1795
|
+
this.#smooth_render_frame += dt_ms / this.tick_period_ms;
|
|
1796
|
+
const alpha = 1 - Math.exp(-PULL_PER_MS * dt_ms);
|
|
1797
|
+
this.#smooth_render_frame += (target - this.#smooth_render_frame) * alpha;
|
|
1798
|
+
}
|
|
1799
|
+
if (this.#smooth_render_frame > this.#latest_received_frame) {
|
|
1800
|
+
this.#smooth_render_frame = this.#latest_received_frame;
|
|
1801
|
+
}
|
|
1802
|
+
if (this.#smooth_render_frame < 0) this.#smooth_render_frame = 0;
|
|
1803
|
+
|
|
1804
|
+
const tick_a = Math.floor(this.#smooth_render_frame);
|
|
1805
|
+
const tick_b = tick_a + 1;
|
|
1806
|
+
const t = this.#smooth_render_frame - tick_a;
|
|
1807
|
+
|
|
1808
|
+
const scratch = this.#scratch_interp_buf;
|
|
1809
|
+
let wrote_anything = false;
|
|
1810
|
+
for (const entity_id of this.#remote_entities) {
|
|
1811
|
+
const identity = this.world.getComponent(entity_id, NetworkIdentity);
|
|
1812
|
+
if (identity === undefined || identity.network_id < 0) continue;
|
|
1813
|
+
for (const [ComponentClass, desc] of this.#replicated) {
|
|
1814
|
+
if (desc.interp === null) continue; // snap mode
|
|
1815
|
+
const live = this.world.getComponent(entity_id, ComponentClass);
|
|
1816
|
+
if (live === undefined) continue;
|
|
1817
|
+
scratch.position = 0;
|
|
1818
|
+
const ok = this.#interp_log.interpolate(
|
|
1819
|
+
scratch, identity.network_id, desc.interp_type_id,
|
|
1820
|
+
tick_a, tick_b, t, desc.interp,
|
|
1821
|
+
);
|
|
1822
|
+
if (!ok) continue;
|
|
1823
|
+
scratch.position = 0;
|
|
1824
|
+
const adapter = this.binary_registry.getAdapter(ComponentClass.typeName);
|
|
1825
|
+
adapter.deserialize(scratch, live);
|
|
1826
|
+
wrote_anything = true;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (wrote_anything) this.render_dirty = true;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// ----------------------------------------------------------------
|
|
1833
|
+
// Utility
|
|
1834
|
+
// ----------------------------------------------------------------
|
|
1835
|
+
|
|
1836
|
+
#assert_not_started(method_name) {
|
|
1837
|
+
if (this.#started) {
|
|
1838
|
+
throw new Error(`NetworkSession.${method_name}: must be called before start()`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|