@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.
Files changed (199) hide show
  1. package/build/bundle-worker-image-decoder.js +1 -1
  2. package/build/bundle-worker-terrain.js +1 -1
  3. package/package.json +1 -1
  4. package/src/core/assert.d.ts +6 -0
  5. package/src/core/assert.d.ts.map +1 -1
  6. package/src/core/assert.js +16 -3
  7. package/src/core/binary/half_to_float_uint16.js +1 -1
  8. package/src/core/binary/to_half_float_uint16.d.ts.map +1 -1
  9. package/src/core/binary/to_half_float_uint16.js +9 -4
  10. package/src/core/collection/table/RowFirstTableSpec.js +1 -1
  11. package/src/core/events/signal/Signal.d.ts.map +1 -1
  12. package/src/core/events/signal/Signal.js +53 -0
  13. package/src/core/math/spline/spline3_hermite_intersection_spline3_hermite_2d.d.ts +3 -3
  14. package/src/core/math/spline/spline3_hermite_intersection_spline3_hermite_2d.js +3 -3
  15. package/src/engine/Clock.d.ts +2 -2
  16. package/src/engine/Clock.js +2 -2
  17. package/src/engine/graphics/ecs/highlight/system/RenderableHighlightSystem.d.ts.map +1 -1
  18. package/src/engine/graphics/ecs/highlight/system/RenderableHighlightSystem.js +17 -29
  19. package/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.d.ts.map +1 -1
  20. package/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.js +18 -31
  21. package/src/engine/network/NetworkSession.d.ts +386 -0
  22. package/src/engine/network/NetworkSession.d.ts.map +1 -0
  23. package/src/engine/network/NetworkSession.js +1841 -0
  24. package/src/engine/network/PriorityFetch.d.ts.map +1 -1
  25. package/src/engine/network/PriorityFetch.js +3 -2
  26. package/src/engine/network/adapters/QuaternionInterpolationAdapter.d.ts +14 -0
  27. package/src/engine/network/adapters/QuaternionInterpolationAdapter.d.ts.map +1 -0
  28. package/src/engine/network/adapters/QuaternionInterpolationAdapter.js +44 -0
  29. package/src/engine/network/adapters/TransformInterpolationAdapter.d.ts +18 -0
  30. package/src/engine/network/adapters/TransformInterpolationAdapter.d.ts.map +1 -0
  31. package/src/engine/network/adapters/TransformInterpolationAdapter.js +79 -0
  32. package/src/engine/network/adapters/TransformReplicationAdapter.d.ts +37 -0
  33. package/src/engine/network/adapters/TransformReplicationAdapter.d.ts.map +1 -0
  34. package/src/engine/network/adapters/TransformReplicationAdapter.js +87 -0
  35. package/src/engine/network/adapters/Vector3InterpolationAdapter.d.ts +18 -0
  36. package/src/engine/network/adapters/Vector3InterpolationAdapter.d.ts.map +1 -0
  37. package/src/engine/network/adapters/Vector3InterpolationAdapter.js +46 -0
  38. package/src/engine/network/convertPathToURL.js +107 -107
  39. package/src/engine/network/core/quantize/quantize_float.d.ts +54 -0
  40. package/src/engine/network/core/quantize/quantize_float.d.ts.map +1 -0
  41. package/src/engine/network/core/quantize/quantize_float.js +66 -0
  42. package/src/engine/network/core/quantize/quantize_position.d.ts +44 -0
  43. package/src/engine/network/core/quantize/quantize_position.d.ts.map +1 -0
  44. package/src/engine/network/core/quantize/quantize_position.js +54 -0
  45. package/src/engine/network/core/sequence/ack_bitfield.d.ts +47 -0
  46. package/src/engine/network/core/sequence/ack_bitfield.d.ts.map +1 -0
  47. package/src/engine/network/core/sequence/ack_bitfield.js +77 -0
  48. package/src/engine/network/core/sequence/seq16.d.ts +53 -0
  49. package/src/engine/network/core/sequence/seq16.d.ts.map +1 -0
  50. package/src/engine/network/core/sequence/seq16.js +69 -0
  51. package/src/engine/network/core/sequence/seq32.d.ts +55 -0
  52. package/src/engine/network/core/sequence/seq32.d.ts.map +1 -0
  53. package/src/engine/network/core/sequence/seq32.js +73 -0
  54. package/src/engine/network/diagnostics/BandwidthMeter.d.ts +76 -0
  55. package/src/engine/network/diagnostics/BandwidthMeter.d.ts.map +1 -0
  56. package/src/engine/network/diagnostics/BandwidthMeter.js +155 -0
  57. package/src/engine/network/diagnostics/ReplayLog.d.ts +74 -0
  58. package/src/engine/network/diagnostics/ReplayLog.d.ts.map +1 -0
  59. package/src/engine/network/diagnostics/ReplayLog.js +137 -0
  60. package/src/engine/network/diagnostics/SyncTest.d.ts +74 -0
  61. package/src/engine/network/diagnostics/SyncTest.d.ts.map +1 -0
  62. package/src/engine/network/diagnostics/SyncTest.js +151 -0
  63. package/src/engine/network/ecs/NetworkSystem.d.ts +57 -0
  64. package/src/engine/network/ecs/NetworkSystem.d.ts.map +1 -0
  65. package/src/engine/network/ecs/NetworkSystem.js +84 -0
  66. package/src/engine/network/ecs/components/NetworkIdentity.d.ts +58 -0
  67. package/src/engine/network/ecs/components/NetworkIdentity.d.ts.map +1 -0
  68. package/src/engine/network/ecs/components/NetworkIdentity.js +73 -0
  69. package/src/engine/network/ecs/serialization/NetworkIdentitySerializationAdapter.d.ts +40 -0
  70. package/src/engine/network/ecs/serialization/NetworkIdentitySerializationAdapter.d.ts.map +1 -0
  71. package/src/engine/network/ecs/serialization/NetworkIdentitySerializationAdapter.js +64 -0
  72. package/src/engine/network/orchestrator/NetworkPeer.d.ts +389 -0
  73. package/src/engine/network/orchestrator/NetworkPeer.d.ts.map +1 -0
  74. package/src/engine/network/orchestrator/NetworkPeer.js +1107 -0
  75. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts +260 -0
  76. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -0
  77. package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +425 -0
  78. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts +217 -0
  79. package/src/engine/network/orchestrator/ServerAuthoritativeServer.d.ts.map +1 -0
  80. package/src/engine/network/orchestrator/ServerAuthoritativeServer.js +562 -0
  81. package/src/engine/network/replication/Replicator.d.ts +134 -0
  82. package/src/engine/network/replication/Replicator.d.ts.map +1 -0
  83. package/src/engine/network/replication/Replicator.js +334 -0
  84. package/src/engine/network/replication/ScopeFilter.d.ts +64 -0
  85. package/src/engine/network/replication/ScopeFilter.d.ts.map +1 -0
  86. package/src/engine/network/replication/ScopeFilter.js +71 -0
  87. package/src/engine/network/sim/ActionLog.d.ts +94 -0
  88. package/src/engine/network/sim/ActionLog.d.ts.map +1 -0
  89. package/src/engine/network/sim/ActionLog.js +189 -0
  90. package/src/engine/network/sim/BinaryInterpolationAdapter.d.ts +58 -0
  91. package/src/engine/network/sim/BinaryInterpolationAdapter.d.ts.map +1 -0
  92. package/src/engine/network/sim/BinaryInterpolationAdapter.js +56 -0
  93. package/src/engine/network/sim/InterpolationLog.d.ts +165 -0
  94. package/src/engine/network/sim/InterpolationLog.d.ts.map +1 -0
  95. package/src/engine/network/sim/InterpolationLog.js +583 -0
  96. package/src/engine/network/sim/ReplicatedComponentRegistry.d.ts +59 -0
  97. package/src/engine/network/sim/ReplicatedComponentRegistry.d.ts.map +1 -0
  98. package/src/engine/network/sim/ReplicatedComponentRegistry.js +140 -0
  99. package/src/engine/network/sim/RewindEngine.d.ts +66 -0
  100. package/src/engine/network/sim/RewindEngine.d.ts.map +1 -0
  101. package/src/engine/network/sim/RewindEngine.js +182 -0
  102. package/src/engine/network/sim/SimAction.d.ts +133 -0
  103. package/src/engine/network/sim/SimAction.d.ts.map +1 -0
  104. package/src/engine/network/sim/SimAction.js +273 -0
  105. package/src/engine/network/sim/SimActionExecutor.d.ts +109 -0
  106. package/src/engine/network/sim/SimActionExecutor.d.ts.map +1 -0
  107. package/src/engine/network/sim/SimActionExecutor.js +238 -0
  108. package/src/engine/network/sim/SimActionRegistry.d.ts +60 -0
  109. package/src/engine/network/sim/SimActionRegistry.d.ts.map +1 -0
  110. package/src/engine/network/sim/SimActionRegistry.js +128 -0
  111. package/src/engine/network/sim/SmoothingState.d.ts +87 -0
  112. package/src/engine/network/sim/SmoothingState.d.ts.map +1 -0
  113. package/src/engine/network/sim/SmoothingState.js +223 -0
  114. package/src/engine/network/sim/Snapshotter.d.ts +98 -0
  115. package/src/engine/network/sim/Snapshotter.d.ts.map +1 -0
  116. package/src/engine/network/sim/Snapshotter.js +206 -0
  117. package/src/engine/network/sim/SpeculationLog.d.ts +53 -0
  118. package/src/engine/network/sim/SpeculationLog.d.ts.map +1 -0
  119. package/src/engine/network/sim/SpeculationLog.js +84 -0
  120. package/src/engine/network/state/Baseline.d.ts +48 -0
  121. package/src/engine/network/state/Baseline.d.ts.map +1 -0
  122. package/src/engine/network/state/Baseline.js +83 -0
  123. package/src/engine/network/state/ChangedEntitySet.d.ts +94 -0
  124. package/src/engine/network/state/ChangedEntitySet.d.ts.map +1 -0
  125. package/src/engine/network/state/ChangedEntitySet.js +256 -0
  126. package/src/engine/network/state/InputRing.d.ts +90 -0
  127. package/src/engine/network/state/InputRing.d.ts.map +1 -0
  128. package/src/engine/network/state/InputRing.js +173 -0
  129. package/src/engine/network/state/MutationLedger.d.ts +82 -0
  130. package/src/engine/network/state/MutationLedger.d.ts.map +1 -0
  131. package/src/engine/network/state/MutationLedger.js +182 -0
  132. package/src/engine/network/state/PriorityAccumulator.d.ts +104 -0
  133. package/src/engine/network/state/PriorityAccumulator.d.ts.map +1 -0
  134. package/src/engine/network/state/PriorityAccumulator.js +180 -0
  135. package/src/engine/network/state/ReplicationSlotTable.d.ts +78 -0
  136. package/src/engine/network/state/ReplicationSlotTable.d.ts.map +1 -0
  137. package/src/engine/network/state/ReplicationSlotTable.js +211 -0
  138. package/src/engine/network/time/AdaptiveRenderDelay.d.ts +128 -0
  139. package/src/engine/network/time/AdaptiveRenderDelay.d.ts.map +1 -0
  140. package/src/engine/network/time/AdaptiveRenderDelay.js +258 -0
  141. package/src/engine/network/time/JitterBuffer.d.ts +58 -0
  142. package/src/engine/network/time/JitterBuffer.d.ts.map +1 -0
  143. package/src/engine/network/time/JitterBuffer.js +116 -0
  144. package/src/engine/network/time/TimeDilation.d.ts +49 -0
  145. package/src/engine/network/time/TimeDilation.d.ts.map +1 -0
  146. package/src/engine/network/time/TimeDilation.js +62 -0
  147. package/src/engine/network/time/TimeSync.d.ts +68 -0
  148. package/src/engine/network/time/TimeSync.d.ts.map +1 -0
  149. package/src/engine/network/time/TimeSync.js +153 -0
  150. package/src/engine/network/transport/Channel.d.ts +74 -0
  151. package/src/engine/network/transport/Channel.d.ts.map +1 -0
  152. package/src/engine/network/transport/Channel.js +272 -0
  153. package/src/engine/network/transport/LoopbackTransport.d.ts +59 -0
  154. package/src/engine/network/transport/LoopbackTransport.d.ts.map +1 -0
  155. package/src/engine/network/transport/LoopbackTransport.js +194 -0
  156. package/src/engine/network/transport/ReliableCommandPipeline.d.ts +139 -0
  157. package/src/engine/network/transport/ReliableCommandPipeline.d.ts.map +1 -0
  158. package/src/engine/network/transport/ReliableCommandPipeline.js +291 -0
  159. package/src/engine/network/transport/Transport.d.ts +109 -0
  160. package/src/engine/network/transport/Transport.d.ts.map +1 -0
  161. package/src/engine/network/transport/Transport.js +119 -0
  162. package/src/engine/network/transport/adapters/NodeUDPTransport.d.ts +60 -0
  163. package/src/engine/network/transport/adapters/NodeUDPTransport.d.ts.map +1 -0
  164. package/src/engine/network/transport/adapters/NodeUDPTransport.js +206 -0
  165. package/src/engine/network/transport/adapters/SimulatedTransport.d.ts +110 -0
  166. package/src/engine/network/transport/adapters/SimulatedTransport.d.ts.map +1 -0
  167. package/src/engine/network/transport/adapters/SimulatedTransport.js +252 -0
  168. package/src/engine/network/transport/adapters/WebRTCDataChannelTransport.d.ts +33 -0
  169. package/src/engine/network/transport/adapters/WebRTCDataChannelTransport.d.ts.map +1 -0
  170. package/src/engine/network/transport/adapters/WebRTCDataChannelTransport.js +131 -0
  171. package/src/engine/network/transport/adapters/WebSocketTransport.d.ts +49 -0
  172. package/src/engine/network/transport/adapters/WebSocketTransport.d.ts.map +1 -0
  173. package/src/engine/network/transport/adapters/WebSocketTransport.js +180 -0
  174. package/src/engine/network/transport/adapters/WebTransportTransport.d.ts +73 -0
  175. package/src/engine/network/transport/adapters/WebTransportTransport.d.ts.map +1 -0
  176. package/src/engine/network/transport/adapters/WebTransportTransport.js +210 -0
  177. package/src/engine/network/transport/fragments/FragmentAssembler.d.ts +104 -0
  178. package/src/engine/network/transport/fragments/FragmentAssembler.d.ts.map +1 -0
  179. package/src/engine/network/transport/fragments/FragmentAssembler.js +291 -0
  180. package/src/engine/network/transport/fragments/FragmentRetention.d.ts +103 -0
  181. package/src/engine/network/transport/fragments/FragmentRetention.d.ts.map +1 -0
  182. package/src/engine/network/transport/fragments/FragmentRetention.js +194 -0
  183. package/src/engine/network/transport/fragments/fragment_send.d.ts +53 -0
  184. package/src/engine/network/transport/fragments/fragment_send.d.ts.map +1 -0
  185. package/src/engine/network/transport/fragments/fragment_send.js +147 -0
  186. package/src/engine/network/transport/fragments/packet_size.d.ts +93 -0
  187. package/src/engine/network/transport/fragments/packet_size.d.ts.map +1 -0
  188. package/src/engine/network/transport/fragments/packet_size.js +101 -0
  189. package/src/engine/network/xhr.js +23 -23
  190. package/src/engine/simulation/Ticker.d.ts +7 -0
  191. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  192. package/src/engine/simulation/Ticker.js +15 -4
  193. package/src/engine/network/DataChannel.js +0 -1210
  194. package/src/engine/network/RemoteController.d.ts +0 -23
  195. package/src/engine/network/RemoteController.d.ts.map +0 -1
  196. package/src/engine/network/RemoteController.js +0 -114
  197. package/src/engine/network/remoteEditor.d.ts +0 -2
  198. package/src/engine/network/remoteEditor.d.ts.map +0 -1
  199. 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
+ }