Skip to main content

sectorsync_runtime/
lib.rs

1//! Multi-station orchestration helpers for `SectorSync`.
2
3#![forbid(unsafe_code)]
4
5pub mod deployment;
6#[cfg(feature = "parallel")]
7mod parallel;
8
9use std::collections::{BTreeMap, HashMap, HashSet};
10
11use sectorsync_core::prelude::{
12    BarrierId, BarrierScope, BarrierState, CellCoord3, CellIndex, CellLoadSample, CellOccupancy,
13    ClientId, CommandEnvelope, CommandId, CommandIngress, CommandQueueError, CommandQueueMode,
14    CommandQueues, ComponentStore, EntityHandle, EntityId, EventQueueError, EventQueueLimits,
15    EventQueues, GatewayError, GatewaySessionTable, HandoffTransfer, HotspotDecision,
16    HotspotPlanner, HotspotSeverity, HotspotSplitScratch, HotspotThresholds, NodeId, OwnerEpoch,
17    PushOutcome, ReplicationBudget, ReplicationPlan, RuntimeBarrier, RuntimeUpgradeHook,
18    SnapshotVersion, SplitProposal, Station, StationError, StationEvent, StationId,
19    StationLoadSample, StationSnapshot, Tick,
20};
21use sectorsync_transport::{
22    InboundPacket, OutboundPacket, StationOutboundPacket, StationTransportReceiver,
23    StationTransportSink, TransportReceiver, TransportSink,
24};
25use sectorsync_wire::{
26    BarrierFrame, BinaryDecodeError, BinaryEncodeError, BinaryFrameDecoder, BinaryFrameEncoder,
27    CommandAckFrame, CommandDispatchFrame, CommandFrame, ComponentSelection, FrameDecoder,
28    FrameEncoder, ReplicationFrame, ReplicationFrameBuildStats, ReplicationFrameBuilder,
29    ReplicationFrameRef, ReplicationFrameRefDecodeError, RuntimeFrame, StationEventFrame,
30};
31
32pub use deployment::{
33    DeploymentConfig, DeploymentError, DeploymentNodeRoute, DeploymentNodeState,
34    DeploymentRouteTable, DeploymentStationMove, DeploymentStationRoute, DeploymentStats,
35    GatewayDeliveryError, GatewayDeliveryRoute,
36};
37#[cfg(feature = "parallel")]
38pub use parallel::{
39    ParallelReplicationScratch, ParallelReplicationView, ReplicationThreadPool,
40    ReplicationThreadPoolBuildError, ReplicationThreadPoolConfig, StationReplicationBatch,
41    StationReplicationBatchSource,
42};
43
44/// Client replication transport bridge configuration.
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
46pub struct ReplicationTransportConfig {
47    /// Planner budget used for every viewer unless the caller builds frames manually.
48    pub budget: ReplicationBudget,
49    /// Whether to send replication frames with no encoded entity deltas.
50    pub send_empty_frames: bool,
51}
52
53/// Client replication transport bridge statistics.
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub struct ReplicationTransportStats {
56    /// Viewer queries planned.
57    pub viewers_planned: usize,
58    /// Frames skipped because they had no encoded entity deltas.
59    pub frames_skipped_empty: usize,
60    /// Frames encoded and sent to client transport.
61    pub frames_sent: usize,
62    /// Bytes sent through client transport.
63    pub bytes_sent: usize,
64    /// Initial packet byte capacity requested from bounded wire hints.
65    pub packet_capacity_hint_bytes: usize,
66    /// Entities selected by AOI planning.
67    pub entities_selected: usize,
68    /// Entities skipped by replication planner budget.
69    pub entities_skipped_by_budget: usize,
70    /// Entities skipped by replication planner cadence.
71    pub entities_skipped_by_cadence: usize,
72    /// Entity deltas encoded into replication frames.
73    pub entities_encoded: usize,
74    /// Component deltas encoded into replication frames.
75    pub components_encoded: usize,
76    /// Entities rolled back because the concrete frame byte budget filled.
77    pub entities_skipped_by_frame_bytes: usize,
78}
79
80/// Result of one viewer replication send attempt.
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub struct ReplicationTransportReport {
83    /// Target client.
84    pub client_id: ClientId,
85    /// Candidate entities selected by the replication planner.
86    pub selected_entities: usize,
87    /// Entity deltas encoded into the frame.
88    pub encoded_entities: usize,
89    /// Component deltas encoded into the frame.
90    pub encoded_components: usize,
91    /// Estimated bytes from the replication planner.
92    pub estimated_plan_bytes: usize,
93    /// Candidate entities skipped because the planner budget was exhausted.
94    pub skipped_by_budget: usize,
95    /// Candidate entities skipped because cadence had not elapsed.
96    pub skipped_by_cadence: usize,
97    /// Entities rolled back because the concrete frame byte budget filled.
98    pub skipped_by_frame_bytes: usize,
99    /// Encoded wire bytes submitted to transport.
100    pub bytes_sent: usize,
101    /// Whether a frame was sent.
102    pub sent: bool,
103}
104
105/// Error produced while planning, building, encoding, or sending replication.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub enum ReplicationTransportError<E> {
108    /// Wire encoding failed.
109    Encode(BinaryEncodeError),
110    /// Underlying client transport failed.
111    Transport(E),
112}
113
114impl<E: core::fmt::Display> core::fmt::Display for ReplicationTransportError<E> {
115    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
116        match self {
117            Self::Encode(error) => write!(f, "{error}"),
118            Self::Transport(error) => write!(f, "{error}"),
119        }
120    }
121}
122
123impl<E> std::error::Error for ReplicationTransportError<E>
124where
125    E: std::error::Error + 'static,
126{
127    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
128        match self {
129            Self::Encode(error) => Some(error),
130            Self::Transport(error) => Some(error),
131        }
132    }
133}
134
135impl<E> From<BinaryEncodeError> for ReplicationTransportError<E> {
136    fn from(value: BinaryEncodeError) -> Self {
137        Self::Encode(value)
138    }
139}
140
141/// Bridge between replication planning/frame building and client packet transport.
142#[derive(Clone, Debug)]
143pub struct ReplicationTransportBridge {
144    config: ReplicationTransportConfig,
145    builder: ReplicationFrameBuilder,
146    stats: ReplicationTransportStats,
147}
148
149impl ReplicationTransportBridge {
150    /// Creates a replication transport bridge.
151    pub const fn new(config: ReplicationTransportConfig, builder: ReplicationFrameBuilder) -> Self {
152        Self {
153            config,
154            builder,
155            stats: ReplicationTransportStats {
156                viewers_planned: 0,
157                frames_skipped_empty: 0,
158                frames_sent: 0,
159                bytes_sent: 0,
160                packet_capacity_hint_bytes: 0,
161                entities_selected: 0,
162                entities_skipped_by_budget: 0,
163                entities_skipped_by_cadence: 0,
164                entities_encoded: 0,
165                components_encoded: 0,
166                entities_skipped_by_frame_bytes: 0,
167            },
168        }
169    }
170
171    /// Returns configuration.
172    pub const fn config(&self) -> ReplicationTransportConfig {
173        self.config
174    }
175
176    /// Returns frame builder configuration.
177    pub const fn builder(&self) -> ReplicationFrameBuilder {
178        self.builder
179    }
180
181    /// Returns accumulated statistics.
182    pub const fn stats(&self) -> ReplicationTransportStats {
183        self.stats
184    }
185
186    /// Builds, encodes, and sends a caller-provided replication plan.
187    #[allow(clippy::too_many_arguments)]
188    pub fn send_plan<T>(
189        &mut self,
190        transport: &mut T,
191        client_id: ClientId,
192        server_tick: Tick,
193        station: &Station,
194        components: &ComponentStore,
195        selection: &ComponentSelection,
196        plan: &ReplicationPlan,
197    ) -> Result<ReplicationTransportReport, ReplicationTransportError<T::Error>>
198    where
199        T: TransportSink,
200    {
201        self.stats.viewers_planned = self.stats.viewers_planned.saturating_add(1);
202        self.stats.entities_selected = self
203            .stats
204            .entities_selected
205            .saturating_add(plan.stats.selected);
206        self.stats.entities_skipped_by_budget = self
207            .stats
208            .entities_skipped_by_budget
209            .saturating_add(plan.stats.skipped_by_budget);
210        self.stats.entities_skipped_by_cadence = self
211            .stats
212            .entities_skipped_by_cadence
213            .saturating_add(plan.stats.skipped_by_cadence);
214
215        let capacity_hint = self
216            .builder
217            .sampled_binary_capacity_hint(station, plan, components, selection)
218            .min(self.config.budget.max_bytes);
219        let mut bytes = Vec::with_capacity(capacity_hint);
220        self.stats.packet_capacity_hint_bytes = self
221            .stats
222            .packet_capacity_hint_bytes
223            .saturating_add(capacity_hint);
224        let build_stats = self.builder.encode_binary_bounded_into(
225            client_id,
226            server_tick,
227            station,
228            plan,
229            components,
230            selection,
231            self.config.budget.max_bytes,
232            &mut bytes,
233        )?;
234        self.stats.entities_encoded = self
235            .stats
236            .entities_encoded
237            .saturating_add(build_stats.encoded_entities);
238        self.stats.components_encoded = self
239            .stats
240            .components_encoded
241            .saturating_add(build_stats.encoded_components);
242        self.stats.entities_skipped_by_frame_bytes = self
243            .stats
244            .entities_skipped_by_frame_bytes
245            .saturating_add(build_stats.skipped_entities_by_frame_bytes);
246
247        if build_stats.encoded_entities == 0 && !self.config.send_empty_frames {
248            self.stats.frames_skipped_empty = self.stats.frames_skipped_empty.saturating_add(1);
249            return Ok(replication_report(client_id, plan, build_stats, 0, false));
250        }
251
252        let byte_len = bytes.len();
253        transport
254            .send(OutboundPacket { client_id, bytes })
255            .map_err(ReplicationTransportError::Transport)?;
256        self.stats.frames_sent = self.stats.frames_sent.saturating_add(1);
257        self.stats.bytes_sent = self.stats.bytes_sent.saturating_add(byte_len);
258
259        Ok(replication_report(
260            client_id,
261            plan,
262            build_stats,
263            byte_len,
264            true,
265        ))
266    }
267}
268
269impl Default for ReplicationTransportBridge {
270    fn default() -> Self {
271        Self::new(
272            ReplicationTransportConfig::default(),
273            ReplicationFrameBuilder::default(),
274        )
275    }
276}
277
278fn replication_report(
279    client_id: ClientId,
280    plan: &ReplicationPlan,
281    build_stats: ReplicationFrameBuildStats,
282    bytes_sent: usize,
283    sent: bool,
284) -> ReplicationTransportReport {
285    ReplicationTransportReport {
286        client_id,
287        selected_entities: plan.stats.selected,
288        encoded_entities: build_stats.encoded_entities,
289        encoded_components: build_stats.encoded_components,
290        estimated_plan_bytes: plan.stats.estimated_bytes,
291        skipped_by_budget: plan.stats.skipped_by_budget,
292        skipped_by_cadence: plan.stats.skipped_by_cadence,
293        skipped_by_frame_bytes: build_stats.skipped_entities_by_frame_bytes,
294        bytes_sent,
295        sent,
296    }
297}
298
299/// Replication receive bridge configuration.
300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub struct ReplicationReceiveConfig {
302    /// Local client id expected inside replication frames.
303    pub client_id: ClientId,
304    /// Expected remote sender identity when the transport can identify it.
305    pub expected_source: Option<ClientId>,
306}
307
308impl ReplicationReceiveConfig {
309    /// Creates receive configuration for a local client.
310    pub const fn new(client_id: ClientId) -> Self {
311        Self {
312            client_id,
313            expected_source: None,
314        }
315    }
316
317    /// Returns a copy that expects packets from `source`.
318    #[must_use]
319    pub const fn with_expected_source(mut self, source: ClientId) -> Self {
320        self.expected_source = Some(source);
321        self
322    }
323}
324
325/// Replication receive bridge statistics.
326#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
327pub struct ReplicationReceiveStats {
328    /// Packets consumed from client transport.
329    pub packets_received: usize,
330    /// Bytes consumed from client transport.
331    pub bytes_received: usize,
332    /// Replication frames decoded and accepted.
333    pub frames_received: usize,
334    /// Packets rejected by wire decoding.
335    pub frames_rejected_decode: usize,
336    /// Packets rejected because they were not replication frames.
337    pub frames_rejected_unexpected: usize,
338    /// Packets rejected because the transport source did not match.
339    pub frames_rejected_source: usize,
340    /// Packets rejected because the frame target did not match this client.
341    pub frames_rejected_target: usize,
342    /// Entity deltas received.
343    pub entities_received: usize,
344    /// Component deltas received.
345    pub components_received: usize,
346}
347
348/// Result of pumping replication packets.
349#[derive(Clone, Debug, Default, PartialEq, Eq)]
350pub struct ReplicationReceivePump {
351    /// Packets consumed from client transport.
352    pub packets_received: usize,
353    /// Bytes consumed from client transport.
354    pub bytes_received: usize,
355    /// Decoded replication frames.
356    pub frames: Vec<ReplicationFrame>,
357}
358
359impl ReplicationReceivePump {
360    /// Returns accepted frame count.
361    pub fn frames_received(&self) -> usize {
362        self.frames.len()
363    }
364
365    /// Returns received entity delta count.
366    pub fn entities_received(&self) -> usize {
367        self.frames.iter().map(|frame| frame.entities.len()).sum()
368    }
369
370    /// Returns received component delta count.
371    pub fn components_received(&self) -> usize {
372        self.frames
373            .iter()
374            .flat_map(|frame| &frame.entities)
375            .map(|entity| entity.components.len())
376            .sum()
377    }
378}
379
380/// Allocation-free summary from visiting replication packets immediately.
381#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
382pub struct ReplicationReceiveVisitReport {
383    /// Packets consumed from client transport.
384    pub packets_received: usize,
385    /// Bytes consumed from client transport.
386    pub bytes_received: usize,
387    /// Replication frames accepted and passed to the visitor.
388    pub frames_received: usize,
389    /// Entity deltas observed across accepted frames.
390    pub entities_received: usize,
391    /// Component deltas observed across accepted frames.
392    pub components_received: usize,
393}
394
395/// Error produced while receiving replication frames.
396#[derive(Clone, Debug, PartialEq, Eq)]
397pub enum ReplicationReceiveError<E> {
398    /// Underlying client transport failed.
399    Transport(E),
400    /// Wire decoding failed.
401    Decode(BinaryDecodeError),
402    /// Packet decoded as a non-replication frame.
403    UnexpectedFrame,
404    /// Packet source did not match expected remote.
405    SourceMismatch {
406        /// Expected source.
407        expected: ClientId,
408        /// Actual source if transport identified one.
409        actual: Option<ClientId>,
410    },
411    /// Replication frame targeted another client.
412    TargetMismatch {
413        /// Expected local client id.
414        expected: ClientId,
415        /// Actual frame target.
416        actual: ClientId,
417    },
418}
419
420impl<E: core::fmt::Display> core::fmt::Display for ReplicationReceiveError<E> {
421    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
422        match self {
423            Self::Transport(error) => write!(f, "{error}"),
424            Self::Decode(error) => write!(f, "{error}"),
425            Self::UnexpectedFrame => f.write_str("client packet was not a replication frame"),
426            Self::SourceMismatch { expected, actual } => write!(
427                f,
428                "replication source mismatch: expected {}, actual {:?}",
429                expected.get(),
430                actual.map(ClientId::get)
431            ),
432            Self::TargetMismatch { expected, actual } => write!(
433                f,
434                "replication target mismatch: expected {}, actual {}",
435                expected.get(),
436                actual.get()
437            ),
438        }
439    }
440}
441
442impl<E> std::error::Error for ReplicationReceiveError<E>
443where
444    E: std::error::Error + 'static,
445{
446    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
447        match self {
448            Self::Transport(error) => Some(error),
449            Self::Decode(error) => Some(error),
450            Self::UnexpectedFrame | Self::SourceMismatch { .. } | Self::TargetMismatch { .. } => {
451                None
452            }
453        }
454    }
455}
456
457impl<E> From<BinaryDecodeError> for ReplicationReceiveError<E> {
458    fn from(value: BinaryDecodeError) -> Self {
459        Self::Decode(value)
460    }
461}
462
463/// Error produced while visiting borrowed replication frames.
464#[derive(Clone, Debug, PartialEq, Eq)]
465pub enum ReplicationReceiveVisitError<T, V> {
466    /// Packet receive, validation, or frame decoding failed.
467    Receive(ReplicationReceiveError<T>),
468    /// The caller-provided frame visitor failed.
469    Visitor(V),
470}
471
472impl<T: core::fmt::Display, V: core::fmt::Display> core::fmt::Display
473    for ReplicationReceiveVisitError<T, V>
474{
475    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
476        match self {
477            Self::Receive(error) => error.fmt(f),
478            Self::Visitor(error) => write!(f, "replication frame visitor failed: {error}"),
479        }
480    }
481}
482
483impl<T, V> std::error::Error for ReplicationReceiveVisitError<T, V>
484where
485    T: std::error::Error + 'static,
486    V: std::error::Error + 'static,
487{
488    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
489        match self {
490            Self::Receive(error) => Some(error),
491            Self::Visitor(error) => Some(error),
492        }
493    }
494}
495
496/// Bridge between client packet transport and decoded replication frames.
497#[derive(Clone, Debug)]
498pub struct ReplicationReceiveBridge {
499    config: ReplicationReceiveConfig,
500    stats: ReplicationReceiveStats,
501}
502
503impl ReplicationReceiveBridge {
504    /// Creates a receive bridge.
505    pub const fn new(config: ReplicationReceiveConfig) -> Self {
506        Self {
507            config,
508            stats: ReplicationReceiveStats {
509                packets_received: 0,
510                bytes_received: 0,
511                frames_received: 0,
512                frames_rejected_decode: 0,
513                frames_rejected_unexpected: 0,
514                frames_rejected_source: 0,
515                frames_rejected_target: 0,
516                entities_received: 0,
517                components_received: 0,
518            },
519        }
520    }
521
522    /// Returns configuration.
523    pub const fn config(&self) -> ReplicationReceiveConfig {
524        self.config
525    }
526
527    /// Returns accumulated statistics.
528    pub const fn stats(&self) -> ReplicationReceiveStats {
529        self.stats
530    }
531
532    /// Receives and decodes up to `max_packets` replication frames.
533    pub fn pump_owned<T>(
534        &mut self,
535        transport: &mut T,
536        max_packets: usize,
537    ) -> Result<ReplicationReceivePump, ReplicationReceiveError<T::Error>>
538    where
539        T: TransportReceiver,
540    {
541        let mut pump = ReplicationReceivePump::default();
542        for _ in 0..max_packets {
543            let Some(packet) = transport
544                .try_recv()
545                .map_err(ReplicationReceiveError::Transport)?
546            else {
547                break;
548            };
549            self.stats.packets_received = self.stats.packets_received.saturating_add(1);
550            self.stats.bytes_received =
551                self.stats.bytes_received.saturating_add(packet.bytes.len());
552            pump.packets_received = pump.packets_received.saturating_add(1);
553            pump.bytes_received = pump.bytes_received.saturating_add(packet.bytes.len());
554
555            if let Some(expected) = self.config.expected_source
556                && packet.client_id != Some(expected)
557            {
558                self.stats.frames_rejected_source =
559                    self.stats.frames_rejected_source.saturating_add(1);
560                return Err(ReplicationReceiveError::SourceMismatch {
561                    expected,
562                    actual: packet.client_id,
563                });
564            }
565
566            let decoded = match BinaryFrameDecoder.decode(&packet.bytes) {
567                Ok(decoded) => decoded,
568                Err(error) => {
569                    self.stats.frames_rejected_decode =
570                        self.stats.frames_rejected_decode.saturating_add(1);
571                    return Err(ReplicationReceiveError::Decode(error));
572                }
573            };
574            let RuntimeFrame::Replication(frame) = decoded else {
575                self.stats.frames_rejected_unexpected =
576                    self.stats.frames_rejected_unexpected.saturating_add(1);
577                return Err(ReplicationReceiveError::UnexpectedFrame);
578            };
579            if frame.client_id != self.config.client_id {
580                self.stats.frames_rejected_target =
581                    self.stats.frames_rejected_target.saturating_add(1);
582                return Err(ReplicationReceiveError::TargetMismatch {
583                    expected: self.config.client_id,
584                    actual: frame.client_id,
585                });
586            }
587
588            self.stats.frames_received = self.stats.frames_received.saturating_add(1);
589            self.stats.entities_received = self
590                .stats
591                .entities_received
592                .saturating_add(frame.entities.len());
593            let components = frame
594                .entities
595                .iter()
596                .map(|entity| entity.components.len())
597                .sum::<usize>();
598            self.stats.components_received =
599                self.stats.components_received.saturating_add(components);
600            pump.frames.push(frame);
601        }
602        Ok(pump)
603    }
604
605    /// Receives validated replication frames and visits borrowed deltas without
606    /// materializing nested owned frame storage.
607    ///
608    /// The visitor must consume borrowed component bytes before returning. Its
609    /// error is surfaced separately from transport and frame-validation errors.
610    /// A frame is counted as accepted after source/wire/target validation and
611    /// before visitor invocation; accumulated bridge statistics are not rolled
612    /// back when the visitor returns an error.
613    pub fn pump<T, F, V>(
614        &mut self,
615        transport: &mut T,
616        max_packets: usize,
617        mut visitor: F,
618    ) -> Result<ReplicationReceiveVisitReport, ReplicationReceiveVisitError<T::Error, V>>
619    where
620        T: TransportReceiver,
621        F: for<'frame> FnMut(ReplicationFrameRef<'frame>) -> Result<(), V>,
622    {
623        let mut report = ReplicationReceiveVisitReport::default();
624        for _ in 0..max_packets {
625            let Some(packet) = transport.try_recv().map_err(|error| {
626                ReplicationReceiveVisitError::Receive(ReplicationReceiveError::Transport(error))
627            })?
628            else {
629                break;
630            };
631            self.stats.packets_received = self.stats.packets_received.saturating_add(1);
632            self.stats.bytes_received =
633                self.stats.bytes_received.saturating_add(packet.bytes.len());
634            report.packets_received = report.packets_received.saturating_add(1);
635            report.bytes_received = report.bytes_received.saturating_add(packet.bytes.len());
636
637            if let Some(expected) = self.config.expected_source
638                && packet.client_id != Some(expected)
639            {
640                self.stats.frames_rejected_source =
641                    self.stats.frames_rejected_source.saturating_add(1);
642                return Err(ReplicationReceiveVisitError::Receive(
643                    ReplicationReceiveError::SourceMismatch {
644                        expected,
645                        actual: packet.client_id,
646                    },
647                ));
648            }
649
650            let frame = match BinaryFrameDecoder.decode_replication(&packet.bytes) {
651                Ok(frame) => frame,
652                Err(ReplicationFrameRefDecodeError::Binary(error)) => {
653                    self.stats.frames_rejected_decode =
654                        self.stats.frames_rejected_decode.saturating_add(1);
655                    return Err(ReplicationReceiveVisitError::Receive(
656                        ReplicationReceiveError::Decode(error),
657                    ));
658                }
659                Err(ReplicationFrameRefDecodeError::UnexpectedFrameKind(_)) => {
660                    self.stats.frames_rejected_unexpected =
661                        self.stats.frames_rejected_unexpected.saturating_add(1);
662                    return Err(ReplicationReceiveVisitError::Receive(
663                        ReplicationReceiveError::UnexpectedFrame,
664                    ));
665                }
666            };
667            if frame.client_id != self.config.client_id {
668                self.stats.frames_rejected_target =
669                    self.stats.frames_rejected_target.saturating_add(1);
670                return Err(ReplicationReceiveVisitError::Receive(
671                    ReplicationReceiveError::TargetMismatch {
672                        expected: self.config.client_id,
673                        actual: frame.client_id,
674                    },
675                ));
676            }
677
678            let entities = frame.encoded_entity_count();
679            let components = frame
680                .entities()
681                .map(sectorsync_wire::EntityDeltaRef::encoded_component_count)
682                .sum::<usize>();
683            self.stats.frames_received = self.stats.frames_received.saturating_add(1);
684            self.stats.entities_received = self.stats.entities_received.saturating_add(entities);
685            self.stats.components_received =
686                self.stats.components_received.saturating_add(components);
687            report.frames_received = report.frames_received.saturating_add(1);
688            report.entities_received = report.entities_received.saturating_add(entities);
689            report.components_received = report.components_received.saturating_add(components);
690            visitor(frame).map_err(ReplicationReceiveVisitError::Visitor)?;
691        }
692        Ok(report)
693    }
694}
695
696/// Low-level client transport bridge configuration.
697#[derive(Clone, Copy, Debug, PartialEq, Eq)]
698pub struct ClientTransportConfig {
699    /// Local client id expected inside client-bound frames.
700    pub client_id: ClientId,
701    /// Remote server/gateway id used as the transport packet target for commands.
702    pub server_id: ClientId,
703    /// Expected remote sender identity when the transport can identify it.
704    pub expected_source: Option<ClientId>,
705}
706
707impl ClientTransportConfig {
708    /// Creates client transport configuration for a server/gateway target.
709    pub const fn new(client_id: ClientId, server_id: ClientId) -> Self {
710        Self {
711            client_id,
712            server_id,
713            expected_source: None,
714        }
715    }
716
717    /// Returns a copy that expects inbound packets from `source`.
718    #[must_use]
719    pub const fn with_expected_source(mut self, source: ClientId) -> Self {
720        self.expected_source = Some(source);
721        self
722    }
723}
724
725/// Low-level client transport bridge statistics.
726#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
727pub struct ClientTransportStats {
728    /// Command frames encoded and submitted to transport.
729    pub commands_sent: usize,
730    /// Command bytes submitted to transport.
731    pub command_bytes_sent: usize,
732    /// Packets consumed from transport.
733    pub packets_received: usize,
734    /// Bytes consumed from transport.
735    pub bytes_received: usize,
736    /// Command ACK frames decoded and accepted.
737    pub command_acks_received: usize,
738    /// Replication frames decoded and accepted.
739    pub replication_frames_received: usize,
740    /// Barrier frames decoded and accepted.
741    pub barrier_frames_received: usize,
742    /// Packets rejected by wire decoding.
743    pub frames_rejected_decode: usize,
744    /// Packets rejected because they were not client-bound frames.
745    pub frames_rejected_unexpected: usize,
746    /// Packets rejected because the transport source did not match.
747    pub frames_rejected_source: usize,
748    /// Packets rejected because the frame target did not match this client.
749    pub frames_rejected_target: usize,
750    /// Entity deltas received in replication frames.
751    pub entities_received: usize,
752    /// Component deltas received in replication frames.
753    pub components_received: usize,
754}
755
756/// Result of sending one command frame.
757#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
758pub struct ClientCommandSendReport {
759    /// Submitted command id.
760    pub command_id: CommandId,
761    /// Encoded command bytes submitted to transport.
762    pub bytes_sent: usize,
763}
764
765/// Client-bound frame categories accepted by `ClientTransportBridge`.
766#[derive(Clone, Copy, Debug, PartialEq, Eq)]
767pub enum ClientInboundFrameKind {
768    /// Command acknowledgement.
769    CommandAck,
770    /// Replication update.
771    Replication,
772    /// Runtime barrier notification.
773    Barrier,
774}
775
776/// Result of pumping client-bound frames.
777#[derive(Clone, Debug, Default, PartialEq, Eq)]
778pub struct ClientTransportPump {
779    /// Packets consumed from transport.
780    pub packets_received: usize,
781    /// Bytes consumed from transport.
782    pub bytes_received: usize,
783    /// Command acknowledgements decoded and accepted.
784    pub command_acks: Vec<CommandAckFrame>,
785    /// Replication frames decoded and accepted.
786    pub replication_frames: Vec<ReplicationFrame>,
787    /// Barrier frames decoded and accepted.
788    pub barriers: Vec<BarrierFrame>,
789}
790
791impl ClientTransportPump {
792    /// Returns received command ACK count.
793    pub fn command_acks_received(&self) -> usize {
794        self.command_acks.len()
795    }
796
797    /// Returns accepted replication frame count.
798    pub fn replication_frames_received(&self) -> usize {
799        self.replication_frames.len()
800    }
801
802    /// Returns accepted barrier frame count.
803    pub fn barrier_frames_received(&self) -> usize {
804        self.barriers.len()
805    }
806
807    /// Returns received entity delta count.
808    pub fn entities_received(&self) -> usize {
809        self.replication_frames
810            .iter()
811            .map(|frame| frame.entities.len())
812            .sum()
813    }
814
815    /// Returns received component delta count.
816    pub fn components_received(&self) -> usize {
817        self.replication_frames
818            .iter()
819            .flat_map(|frame| &frame.entities)
820            .map(|entity| entity.components.len())
821            .sum()
822    }
823}
824
825/// Client-bound frame visited without materializing replication delta storage.
826#[derive(Clone, Debug)]
827pub enum ClientInboundFrameRef<'a> {
828    /// Decoded command acknowledgement value.
829    CommandAck(CommandAckFrame),
830    /// Validated replication frame borrowing entity/component payload bytes.
831    Replication(ReplicationFrameRef<'a>),
832    /// Decoded runtime barrier notification value.
833    Barrier(BarrierFrame),
834}
835
836/// Fixed-size result of visiting client-bound transport frames.
837#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
838pub struct ClientTransportVisitReport {
839    /// Packets consumed from transport.
840    pub packets_received: usize,
841    /// Bytes consumed from transport.
842    pub bytes_received: usize,
843    /// Command acknowledgements decoded and accepted.
844    pub command_acks_received: usize,
845    /// Replication frames decoded and accepted.
846    pub replication_frames_received: usize,
847    /// Barrier frames decoded and accepted.
848    pub barrier_frames_received: usize,
849    /// Entity deltas visited in replication frames.
850    pub entities_received: usize,
851    /// Component deltas visited in replication frames.
852    pub components_received: usize,
853}
854
855/// Error produced while visiting client-bound transport frames.
856#[derive(Clone, Debug, PartialEq, Eq)]
857pub enum ClientTransportVisitError<T, V> {
858    /// Packet receive, validation, or frame decoding failed.
859    Receive(ClientTransportBridgeError<T>),
860    /// The caller-provided frame visitor failed.
861    Visitor(V),
862}
863
864impl<T: core::fmt::Display, V: core::fmt::Display> core::fmt::Display
865    for ClientTransportVisitError<T, V>
866{
867    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
868        match self {
869            Self::Receive(error) => error.fmt(formatter),
870            Self::Visitor(error) => write!(formatter, "client frame visitor failed: {error}"),
871        }
872    }
873}
874
875impl<T, V> std::error::Error for ClientTransportVisitError<T, V>
876where
877    T: std::error::Error + 'static,
878    V: std::error::Error + 'static,
879{
880    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
881        match self {
882            Self::Receive(error) => Some(error),
883            Self::Visitor(error) => Some(error),
884        }
885    }
886}
887
888/// Error produced by the low-level client transport bridge.
889#[derive(Clone, Debug, PartialEq, Eq)]
890pub enum ClientTransportBridgeError<E> {
891    /// Outbound command used a different client id than the bridge config.
892    CommandClientMismatch {
893        /// Expected local client id.
894        expected: ClientId,
895        /// Actual command client id.
896        actual: ClientId,
897    },
898    /// Wire encoding failed.
899    Encode(BinaryEncodeError),
900    /// Underlying client transport failed.
901    Transport(E),
902    /// Wire decoding failed.
903    Decode(BinaryDecodeError),
904    /// Packet decoded as a frame that is not client-bound.
905    UnexpectedFrame,
906    /// Packet source did not match expected remote.
907    SourceMismatch {
908        /// Expected source.
909        expected: ClientId,
910        /// Actual source if transport identified one.
911        actual: Option<ClientId>,
912    },
913    /// Client-bound frame targeted another client.
914    TargetMismatch {
915        /// Frame category.
916        kind: ClientInboundFrameKind,
917        /// Expected local client id.
918        expected: ClientId,
919        /// Actual frame target.
920        actual: ClientId,
921    },
922}
923
924impl<E: core::fmt::Display> core::fmt::Display for ClientTransportBridgeError<E> {
925    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
926        match self {
927            Self::CommandClientMismatch { expected, actual } => write!(
928                f,
929                "command client mismatch: expected {}, actual {}",
930                expected.get(),
931                actual.get()
932            ),
933            Self::Encode(error) => write!(f, "{error}"),
934            Self::Transport(error) => write!(f, "{error}"),
935            Self::Decode(error) => write!(f, "{error}"),
936            Self::UnexpectedFrame => f.write_str("packet was not a client-bound frame"),
937            Self::SourceMismatch { expected, actual } => write!(
938                f,
939                "client packet source mismatch: expected {}, actual {:?}",
940                expected.get(),
941                actual.map(ClientId::get)
942            ),
943            Self::TargetMismatch {
944                kind,
945                expected,
946                actual,
947            } => write!(
948                f,
949                "client {:?} frame target mismatch: expected {}, actual {}",
950                kind,
951                expected.get(),
952                actual.get()
953            ),
954        }
955    }
956}
957
958impl<E> std::error::Error for ClientTransportBridgeError<E>
959where
960    E: std::error::Error + 'static,
961{
962    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
963        match self {
964            Self::Encode(error) => Some(error),
965            Self::Transport(error) => Some(error),
966            Self::Decode(error) => Some(error),
967            Self::CommandClientMismatch { .. }
968            | Self::UnexpectedFrame
969            | Self::SourceMismatch { .. }
970            | Self::TargetMismatch { .. } => None,
971        }
972    }
973}
974
975impl<E> From<BinaryEncodeError> for ClientTransportBridgeError<E> {
976    fn from(value: BinaryEncodeError) -> Self {
977        Self::Encode(value)
978    }
979}
980
981impl<E> From<BinaryDecodeError> for ClientTransportBridgeError<E> {
982    fn from(value: BinaryDecodeError) -> Self {
983        Self::Decode(value)
984    }
985}
986
987/// Low-level bridge for client command send and client-bound frame receive.
988#[derive(Clone, Debug)]
989pub struct ClientTransportBridge {
990    config: ClientTransportConfig,
991    stats: ClientTransportStats,
992}
993
994impl ClientTransportBridge {
995    /// Creates a client transport bridge.
996    pub const fn new(config: ClientTransportConfig) -> Self {
997        Self {
998            config,
999            stats: ClientTransportStats {
1000                commands_sent: 0,
1001                command_bytes_sent: 0,
1002                packets_received: 0,
1003                bytes_received: 0,
1004                command_acks_received: 0,
1005                replication_frames_received: 0,
1006                barrier_frames_received: 0,
1007                frames_rejected_decode: 0,
1008                frames_rejected_unexpected: 0,
1009                frames_rejected_source: 0,
1010                frames_rejected_target: 0,
1011                entities_received: 0,
1012                components_received: 0,
1013            },
1014        }
1015    }
1016
1017    /// Returns configuration.
1018    pub const fn config(&self) -> ClientTransportConfig {
1019        self.config
1020    }
1021
1022    /// Returns accumulated statistics.
1023    pub const fn stats(&self) -> ClientTransportStats {
1024        self.stats
1025    }
1026
1027    /// Encodes and sends a client command frame to the configured server id.
1028    pub fn send_command_frame<T>(
1029        &mut self,
1030        transport: &mut T,
1031        frame: &CommandFrame,
1032    ) -> Result<ClientCommandSendReport, ClientTransportBridgeError<T::Error>>
1033    where
1034        T: TransportSink,
1035    {
1036        if frame.client_id != self.config.client_id {
1037            return Err(ClientTransportBridgeError::CommandClientMismatch {
1038                expected: self.config.client_id,
1039                actual: frame.client_id,
1040            });
1041        }
1042
1043        let mut bytes = Vec::new();
1044        BinaryFrameEncoder.encode_command(frame, &mut bytes)?;
1045        let bytes_sent = bytes.len();
1046        transport
1047            .send(OutboundPacket {
1048                client_id: self.config.server_id,
1049                bytes,
1050            })
1051            .map_err(ClientTransportBridgeError::Transport)?;
1052        self.stats.commands_sent = self.stats.commands_sent.saturating_add(1);
1053        self.stats.command_bytes_sent = self.stats.command_bytes_sent.saturating_add(bytes_sent);
1054
1055        Ok(ClientCommandSendReport {
1056            command_id: frame.command_id,
1057            bytes_sent,
1058        })
1059    }
1060
1061    /// Receives and decodes up to `max_packets` client-bound frames.
1062    pub fn pump_owned<T>(
1063        &mut self,
1064        transport: &mut T,
1065        max_packets: usize,
1066    ) -> Result<ClientTransportPump, ClientTransportBridgeError<T::Error>>
1067    where
1068        T: TransportReceiver,
1069    {
1070        let mut pump = ClientTransportPump::default();
1071        for _ in 0..max_packets {
1072            let Some(packet) = transport
1073                .try_recv()
1074                .map_err(ClientTransportBridgeError::Transport)?
1075            else {
1076                break;
1077            };
1078            self.stats.packets_received = self.stats.packets_received.saturating_add(1);
1079            self.stats.bytes_received =
1080                self.stats.bytes_received.saturating_add(packet.bytes.len());
1081            pump.packets_received = pump.packets_received.saturating_add(1);
1082            pump.bytes_received = pump.bytes_received.saturating_add(packet.bytes.len());
1083
1084            if let Some(expected) = self.config.expected_source
1085                && packet.client_id != Some(expected)
1086            {
1087                self.stats.frames_rejected_source =
1088                    self.stats.frames_rejected_source.saturating_add(1);
1089                return Err(ClientTransportBridgeError::SourceMismatch {
1090                    expected,
1091                    actual: packet.client_id,
1092                });
1093            }
1094
1095            let decoded = match BinaryFrameDecoder.decode(&packet.bytes) {
1096                Ok(decoded) => decoded,
1097                Err(error) => {
1098                    self.stats.frames_rejected_decode =
1099                        self.stats.frames_rejected_decode.saturating_add(1);
1100                    return Err(ClientTransportBridgeError::Decode(error));
1101                }
1102            };
1103            match decoded {
1104                RuntimeFrame::CommandAck(frame) => {
1105                    self.validate_client_target(
1106                        ClientInboundFrameKind::CommandAck,
1107                        frame.client_id,
1108                    )?;
1109                    self.stats.command_acks_received =
1110                        self.stats.command_acks_received.saturating_add(1);
1111                    pump.command_acks.push(frame);
1112                }
1113                RuntimeFrame::Replication(frame) => {
1114                    self.validate_client_target(
1115                        ClientInboundFrameKind::Replication,
1116                        frame.client_id,
1117                    )?;
1118                    self.stats.replication_frames_received =
1119                        self.stats.replication_frames_received.saturating_add(1);
1120                    self.stats.entities_received = self
1121                        .stats
1122                        .entities_received
1123                        .saturating_add(frame.entities.len());
1124                    let components = frame
1125                        .entities
1126                        .iter()
1127                        .map(|entity| entity.components.len())
1128                        .sum::<usize>();
1129                    self.stats.components_received =
1130                        self.stats.components_received.saturating_add(components);
1131                    pump.replication_frames.push(frame);
1132                }
1133                RuntimeFrame::Barrier(frame) => {
1134                    self.validate_client_target(ClientInboundFrameKind::Barrier, frame.client_id)?;
1135                    self.stats.barrier_frames_received =
1136                        self.stats.barrier_frames_received.saturating_add(1);
1137                    pump.barriers.push(frame);
1138                }
1139                RuntimeFrame::Command(_)
1140                | RuntimeFrame::CommandDispatch(_)
1141                | RuntimeFrame::StationEvent(_) => {
1142                    self.stats.frames_rejected_unexpected =
1143                        self.stats.frames_rejected_unexpected.saturating_add(1);
1144                    return Err(ClientTransportBridgeError::UnexpectedFrame);
1145                }
1146            }
1147        }
1148        Ok(pump)
1149    }
1150
1151    /// Receives mixed client-bound frames and visits them without materializing
1152    /// nested replication frame storage.
1153    ///
1154    /// ACK and barrier values are copied into the visitor enum. Replication
1155    /// component bytes borrow the current transport packet and must be consumed
1156    /// before the visitor returns. Accepted statistics are recorded before
1157    /// visitor invocation and are not rolled back if the visitor fails.
1158    #[allow(clippy::too_many_lines)]
1159    pub fn pump<T, F, V>(
1160        &mut self,
1161        transport: &mut T,
1162        max_packets: usize,
1163        mut visitor: F,
1164    ) -> Result<ClientTransportVisitReport, ClientTransportVisitError<T::Error, V>>
1165    where
1166        T: TransportReceiver,
1167        F: for<'frame> FnMut(ClientInboundFrameRef<'frame>) -> Result<(), V>,
1168    {
1169        let mut report = ClientTransportVisitReport::default();
1170        for _ in 0..max_packets {
1171            let Some(packet) = transport.try_recv().map_err(|error| {
1172                ClientTransportVisitError::Receive(ClientTransportBridgeError::Transport(error))
1173            })?
1174            else {
1175                break;
1176            };
1177            self.stats.packets_received = self.stats.packets_received.saturating_add(1);
1178            self.stats.bytes_received =
1179                self.stats.bytes_received.saturating_add(packet.bytes.len());
1180            report.packets_received = report.packets_received.saturating_add(1);
1181            report.bytes_received = report.bytes_received.saturating_add(packet.bytes.len());
1182
1183            if let Some(expected) = self.config.expected_source
1184                && packet.client_id != Some(expected)
1185            {
1186                self.stats.frames_rejected_source =
1187                    self.stats.frames_rejected_source.saturating_add(1);
1188                return Err(ClientTransportVisitError::Receive(
1189                    ClientTransportBridgeError::SourceMismatch {
1190                        expected,
1191                        actual: packet.client_id,
1192                    },
1193                ));
1194            }
1195
1196            match BinaryFrameDecoder.decode_replication(&packet.bytes) {
1197                Ok(frame) => {
1198                    self.validate_client_target::<T::Error>(
1199                        ClientInboundFrameKind::Replication,
1200                        frame.client_id,
1201                    )
1202                    .map_err(ClientTransportVisitError::Receive)?;
1203                    let entities = frame.encoded_entity_count();
1204                    let components = frame
1205                        .entities()
1206                        .map(sectorsync_wire::EntityDeltaRef::encoded_component_count)
1207                        .sum::<usize>();
1208                    self.stats.replication_frames_received =
1209                        self.stats.replication_frames_received.saturating_add(1);
1210                    self.stats.entities_received =
1211                        self.stats.entities_received.saturating_add(entities);
1212                    self.stats.components_received =
1213                        self.stats.components_received.saturating_add(components);
1214                    report.replication_frames_received =
1215                        report.replication_frames_received.saturating_add(1);
1216                    report.entities_received = report.entities_received.saturating_add(entities);
1217                    report.components_received =
1218                        report.components_received.saturating_add(components);
1219                    visitor(ClientInboundFrameRef::Replication(frame))
1220                        .map_err(ClientTransportVisitError::Visitor)?;
1221                }
1222                Err(ReplicationFrameRefDecodeError::Binary(error)) => {
1223                    self.stats.frames_rejected_decode =
1224                        self.stats.frames_rejected_decode.saturating_add(1);
1225                    return Err(ClientTransportVisitError::Receive(
1226                        ClientTransportBridgeError::Decode(error),
1227                    ));
1228                }
1229                Err(ReplicationFrameRefDecodeError::UnexpectedFrameKind(_)) => {
1230                    let decoded = BinaryFrameDecoder.decode(&packet.bytes).map_err(|error| {
1231                        self.stats.frames_rejected_decode =
1232                            self.stats.frames_rejected_decode.saturating_add(1);
1233                        ClientTransportVisitError::Receive(ClientTransportBridgeError::Decode(
1234                            error,
1235                        ))
1236                    })?;
1237                    match decoded {
1238                        RuntimeFrame::CommandAck(frame) => {
1239                            self.validate_client_target::<T::Error>(
1240                                ClientInboundFrameKind::CommandAck,
1241                                frame.client_id,
1242                            )
1243                            .map_err(ClientTransportVisitError::Receive)?;
1244                            self.stats.command_acks_received =
1245                                self.stats.command_acks_received.saturating_add(1);
1246                            report.command_acks_received =
1247                                report.command_acks_received.saturating_add(1);
1248                            visitor(ClientInboundFrameRef::CommandAck(frame))
1249                                .map_err(ClientTransportVisitError::Visitor)?;
1250                        }
1251                        RuntimeFrame::Barrier(frame) => {
1252                            self.validate_client_target::<T::Error>(
1253                                ClientInboundFrameKind::Barrier,
1254                                frame.client_id,
1255                            )
1256                            .map_err(ClientTransportVisitError::Receive)?;
1257                            self.stats.barrier_frames_received =
1258                                self.stats.barrier_frames_received.saturating_add(1);
1259                            report.barrier_frames_received =
1260                                report.barrier_frames_received.saturating_add(1);
1261                            visitor(ClientInboundFrameRef::Barrier(frame))
1262                                .map_err(ClientTransportVisitError::Visitor)?;
1263                        }
1264                        RuntimeFrame::Replication(_)
1265                        | RuntimeFrame::Command(_)
1266                        | RuntimeFrame::CommandDispatch(_)
1267                        | RuntimeFrame::StationEvent(_) => {
1268                            self.stats.frames_rejected_unexpected =
1269                                self.stats.frames_rejected_unexpected.saturating_add(1);
1270                            return Err(ClientTransportVisitError::Receive(
1271                                ClientTransportBridgeError::UnexpectedFrame,
1272                            ));
1273                        }
1274                    }
1275                }
1276            }
1277        }
1278        Ok(report)
1279    }
1280
1281    fn validate_client_target<E>(
1282        &mut self,
1283        kind: ClientInboundFrameKind,
1284        actual: ClientId,
1285    ) -> Result<(), ClientTransportBridgeError<E>> {
1286        if actual == self.config.client_id {
1287            return Ok(());
1288        }
1289        self.stats.frames_rejected_target = self.stats.frames_rejected_target.saturating_add(1);
1290        Err(ClientTransportBridgeError::TargetMismatch {
1291            kind,
1292            expected: self.config.client_id,
1293            actual,
1294        })
1295    }
1296}
1297
1298/// Runtime barrier notification transport statistics.
1299#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1300pub struct BarrierTransportStats {
1301    /// Barrier frames encoded and submitted to client transport.
1302    pub notifications_sent: usize,
1303    /// Client targets submitted to transport.
1304    pub clients_notified: usize,
1305    /// Encoded bytes submitted to transport.
1306    pub bytes_sent: usize,
1307}
1308
1309/// Result of one barrier notification broadcast.
1310#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1311pub struct BarrierTransportReport {
1312    /// Barrier id.
1313    pub barrier_id: BarrierId,
1314    /// Barrier state sent to clients.
1315    pub state: BarrierState,
1316    /// Server tick associated with the notification.
1317    pub server_tick: Tick,
1318    /// Client targets requested by the caller.
1319    pub clients_requested: usize,
1320    /// Client targets successfully submitted to transport.
1321    pub clients_sent: usize,
1322    /// Encoded bytes submitted to transport.
1323    pub bytes_sent: usize,
1324}
1325
1326/// Error produced while encoding or sending barrier notifications.
1327#[derive(Clone, Debug, PartialEq, Eq)]
1328pub enum BarrierTransportError<E> {
1329    /// Wire encoding failed.
1330    Encode(BinaryEncodeError),
1331    /// Underlying client transport failed.
1332    Transport(E),
1333}
1334
1335impl<E: core::fmt::Display> core::fmt::Display for BarrierTransportError<E> {
1336    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1337        match self {
1338            Self::Encode(error) => write!(f, "{error}"),
1339            Self::Transport(error) => write!(f, "{error}"),
1340        }
1341    }
1342}
1343
1344impl<E> std::error::Error for BarrierTransportError<E>
1345where
1346    E: std::error::Error + 'static,
1347{
1348    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1349        match self {
1350            Self::Encode(error) => Some(error),
1351            Self::Transport(error) => Some(error),
1352        }
1353    }
1354}
1355
1356impl<E> From<BinaryEncodeError> for BarrierTransportError<E> {
1357    fn from(value: BinaryEncodeError) -> Self {
1358        Self::Encode(value)
1359    }
1360}
1361
1362/// Low-level bridge for sending runtime barrier notifications to clients.
1363#[derive(Clone, Debug, Default)]
1364pub struct BarrierTransportBridge {
1365    stats: BarrierTransportStats,
1366}
1367
1368impl BarrierTransportBridge {
1369    /// Creates a barrier notification transport bridge.
1370    pub const fn new() -> Self {
1371        Self {
1372            stats: BarrierTransportStats {
1373                notifications_sent: 0,
1374                clients_notified: 0,
1375                bytes_sent: 0,
1376            },
1377        }
1378    }
1379
1380    /// Returns accumulated statistics.
1381    pub const fn stats(&self) -> BarrierTransportStats {
1382        self.stats
1383    }
1384
1385    /// Sends one barrier notification to one client.
1386    pub fn send_state<T>(
1387        &mut self,
1388        transport: &mut T,
1389        client_id: ClientId,
1390        barrier_id: BarrierId,
1391        server_tick: Tick,
1392        state: BarrierState,
1393    ) -> Result<usize, BarrierTransportError<T::Error>>
1394    where
1395        T: TransportSink,
1396    {
1397        let frame = BarrierFrame {
1398            client_id,
1399            barrier_id,
1400            server_tick,
1401            state,
1402        };
1403        let mut bytes = Vec::new();
1404        BinaryFrameEncoder.encode_barrier(&frame, &mut bytes)?;
1405        let bytes_sent = bytes.len();
1406        transport
1407            .send(OutboundPacket { client_id, bytes })
1408            .map_err(BarrierTransportError::Transport)?;
1409        self.stats.notifications_sent = self.stats.notifications_sent.saturating_add(1);
1410        self.stats.clients_notified = self.stats.clients_notified.saturating_add(1);
1411        self.stats.bytes_sent = self.stats.bytes_sent.saturating_add(bytes_sent);
1412        Ok(bytes_sent)
1413    }
1414
1415    /// Sends one runtime barrier notification to one client.
1416    pub fn send_barrier<T>(
1417        &mut self,
1418        transport: &mut T,
1419        client_id: ClientId,
1420        barrier: RuntimeBarrier,
1421    ) -> Result<usize, BarrierTransportError<T::Error>>
1422    where
1423        T: TransportSink,
1424    {
1425        self.send_state(
1426            transport,
1427            client_id,
1428            barrier.id,
1429            barrier.target_tick,
1430            barrier.state,
1431        )
1432    }
1433
1434    /// Broadcasts one barrier state to a bounded caller-provided client list.
1435    pub fn broadcast_state<T, I>(
1436        &mut self,
1437        transport: &mut T,
1438        clients: I,
1439        barrier_id: BarrierId,
1440        server_tick: Tick,
1441        state: BarrierState,
1442    ) -> Result<BarrierTransportReport, BarrierTransportError<T::Error>>
1443    where
1444        T: TransportSink,
1445        I: IntoIterator<Item = ClientId>,
1446    {
1447        let mut report = BarrierTransportReport {
1448            barrier_id,
1449            state,
1450            server_tick,
1451            clients_requested: 0,
1452            clients_sent: 0,
1453            bytes_sent: 0,
1454        };
1455        for client_id in clients {
1456            report.clients_requested = report.clients_requested.saturating_add(1);
1457            let bytes_sent =
1458                self.send_state(transport, client_id, barrier_id, server_tick, state)?;
1459            report.clients_sent = report.clients_sent.saturating_add(1);
1460            report.bytes_sent = report.bytes_sent.saturating_add(bytes_sent);
1461        }
1462        Ok(report)
1463    }
1464
1465    /// Broadcasts one runtime barrier to a bounded caller-provided client list.
1466    pub fn broadcast_barrier<T, I>(
1467        &mut self,
1468        transport: &mut T,
1469        clients: I,
1470        barrier: RuntimeBarrier,
1471    ) -> Result<BarrierTransportReport, BarrierTransportError<T::Error>>
1472    where
1473        T: TransportSink,
1474        I: IntoIterator<Item = ClientId>,
1475    {
1476        self.broadcast_state(
1477            transport,
1478            clients,
1479            barrier.id,
1480            barrier.target_tick,
1481            barrier.state,
1482        )
1483    }
1484}
1485
1486/// Accepted command ACK reason code.
1487pub const GATEWAY_COMMAND_ACK_ACCEPTED: u16 = 0;
1488/// Command was rejected by generic gateway/session state.
1489pub const GATEWAY_COMMAND_ACK_GATEWAY_REJECTED: u16 = 1;
1490/// Command was rejected by gateway rate limiting.
1491pub const GATEWAY_COMMAND_ACK_RATE_LIMITED: u16 = 2;
1492/// Command was rejected as stale or replayed.
1493pub const GATEWAY_COMMAND_ACK_REPLAY_OR_STALE: u16 = 3;
1494/// Command could not be queued because a target station queue was full.
1495pub const GATEWAY_COMMAND_ACK_QUEUE_FULL: u16 = 4;
1496/// Command was rejected by the station barrier ingress policy.
1497pub const GATEWAY_COMMAND_ACK_BARRIER_REJECTED: u16 = 5;
1498/// Command route pointed at a station queue that was not registered.
1499pub const GATEWAY_COMMAND_ACK_MISSING_QUEUE: u16 = 6;
1500/// Command could not be resolved through deployment metadata.
1501pub const GATEWAY_COMMAND_ACK_DEPLOYMENT_REJECTED: u16 = 7;
1502
1503/// Gateway command pipeline configuration.
1504#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1505pub struct GatewayCommandPipelineConfig {
1506    /// Encode negative ACKs for gateway/queue rejections.
1507    pub ack_rejections: bool,
1508}
1509
1510impl Default for GatewayCommandPipelineConfig {
1511    fn default() -> Self {
1512        Self {
1513            ack_rejections: true,
1514        }
1515    }
1516}
1517
1518/// Gateway command pipeline statistics.
1519#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1520pub struct GatewayCommandPipelineStats {
1521    /// Command frames decoded.
1522    pub command_frames_decoded: usize,
1523    /// Frames rejected by the binary decoder.
1524    pub frames_rejected_decode: usize,
1525    /// Non-command frames rejected by this pipeline.
1526    pub frames_rejected_non_command: usize,
1527    /// Commands admitted by gateway/session metadata.
1528    pub commands_admitted: usize,
1529    /// Commands enqueued into target station queues.
1530    pub commands_enqueued: usize,
1531    /// Commands rejected by gateway/session metadata.
1532    pub commands_rejected_gateway: usize,
1533    /// Commands rejected by station queue or station queue lookup.
1534    pub commands_rejected_queue: usize,
1535    /// Commands resolved to deployment node delivery routes.
1536    pub commands_routed_deployment: usize,
1537    /// Commands rejected by deployment node/station route metadata.
1538    pub commands_rejected_deployment: usize,
1539    /// ACK frames encoded.
1540    pub acks_encoded: usize,
1541}
1542
1543/// Gateway command pipeline error.
1544#[derive(Clone, Debug, PartialEq, Eq)]
1545pub enum GatewayCommandPipelineError {
1546    /// Wire decode failed.
1547    Decode(BinaryDecodeError),
1548    /// Frame decoded correctly but was not a command frame.
1549    NonCommandFrame,
1550    /// Gateway/session metadata rejected the command.
1551    Gateway(GatewayError),
1552    /// Gateway route pointed at a missing station queue.
1553    MissingQueue(StationId),
1554    /// Target station queue rejected the command.
1555    Queue(CommandQueueError),
1556    /// Deployment route metadata rejected command delivery.
1557    Deployment(DeploymentError),
1558    /// ACK encode failed.
1559    Encode(BinaryEncodeError),
1560}
1561
1562impl core::fmt::Display for GatewayCommandPipelineError {
1563    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1564        match self {
1565            Self::Decode(error) => write!(f, "{error}"),
1566            Self::NonCommandFrame => f.write_str("gateway command pipeline expected command frame"),
1567            Self::Gateway(error) => write!(f, "{error}"),
1568            Self::MissingQueue(station_id) => write!(
1569                f,
1570                "gateway command route target station {} has no queue",
1571                station_id.get()
1572            ),
1573            Self::Queue(error) => write!(f, "{error}"),
1574            Self::Deployment(error) => write!(f, "{error}"),
1575            Self::Encode(error) => write!(f, "{error}"),
1576        }
1577    }
1578}
1579
1580impl std::error::Error for GatewayCommandPipelineError {
1581    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1582        match self {
1583            Self::Decode(error) => Some(error),
1584            Self::Gateway(error) => Some(error),
1585            Self::Queue(error) => Some(error),
1586            Self::Deployment(error) => Some(error),
1587            Self::Encode(error) => Some(error),
1588            Self::NonCommandFrame | Self::MissingQueue(_) => None,
1589        }
1590    }
1591}
1592
1593/// Gateway command pipeline result.
1594#[derive(Clone, Debug, Default, PartialEq, Eq)]
1595pub struct GatewayCommandPipelineReport {
1596    /// Client id when a command frame was decoded.
1597    pub client_id: Option<ClientId>,
1598    /// Command id when a command frame was decoded.
1599    pub command_id: Option<CommandId>,
1600    /// Target station when gateway routing succeeded.
1601    pub station_id: Option<StationId>,
1602    /// Target node when deployment routing succeeded.
1603    pub node_id: Option<NodeId>,
1604    /// Resolved deployment delivery route.
1605    pub delivery: Option<GatewayDeliveryRoute>,
1606    /// Stamped command envelope for external dispatch, when not enqueued locally.
1607    pub command: Option<CommandEnvelope>,
1608    /// Whether the command was queued for station application.
1609    pub accepted: bool,
1610    /// ACK reason code. Zero means accepted.
1611    pub reason_code: u16,
1612    /// Encoded ACK bytes, when an ACK was produced.
1613    pub ack_bytes: Option<Vec<u8>>,
1614    /// Decode, gateway, queue, or encode error detail.
1615    pub error: Option<GatewayCommandPipelineError>,
1616}
1617
1618/// Business-agnostic gateway command frame pipeline.
1619#[derive(Clone, Debug)]
1620pub struct GatewayCommandPipeline {
1621    config: GatewayCommandPipelineConfig,
1622    decoder: BinaryFrameDecoder,
1623    encoder: BinaryFrameEncoder,
1624    stats: GatewayCommandPipelineStats,
1625}
1626
1627impl GatewayCommandPipeline {
1628    /// Creates a pipeline.
1629    pub fn new(config: GatewayCommandPipelineConfig) -> Self {
1630        Self {
1631            config,
1632            decoder: BinaryFrameDecoder,
1633            encoder: BinaryFrameEncoder,
1634            stats: GatewayCommandPipelineStats::default(),
1635        }
1636    }
1637
1638    /// Returns configuration.
1639    pub const fn config(&self) -> GatewayCommandPipelineConfig {
1640        self.config
1641    }
1642
1643    /// Returns statistics.
1644    pub const fn stats(&self) -> GatewayCommandPipelineStats {
1645        self.stats
1646    }
1647
1648    /// Processes one decoded-transport command packet and optionally produces
1649    /// an encoded command ACK.
1650    pub fn process(
1651        &mut self,
1652        gateway: &mut GatewaySessionTable,
1653        station_queues: &mut BTreeMap<StationId, CommandQueues>,
1654        input: &[u8],
1655        now: Tick,
1656        ingress: CommandIngress,
1657    ) -> GatewayCommandPipelineReport {
1658        let command_frame = match self.decode_command_frame(input) {
1659            Ok(command_frame) => command_frame,
1660            Err(error) => {
1661                return GatewayCommandPipelineReport {
1662                    error: Some(error),
1663                    ..GatewayCommandPipelineReport::default()
1664                };
1665            }
1666        };
1667
1668        self.process_command_frame(gateway, station_queues, command_frame, now, ingress)
1669    }
1670
1671    /// Decodes and admits one command packet, then resolves a deployment route
1672    /// for external node dispatch without touching local station queues.
1673    pub fn dispatch(
1674        &mut self,
1675        gateway: &mut GatewaySessionTable,
1676        deployment: &DeploymentRouteTable,
1677        input: &[u8],
1678        now: Tick,
1679    ) -> GatewayCommandPipelineReport {
1680        let command_frame = match self.decode_command_frame(input) {
1681            Ok(command_frame) => command_frame,
1682            Err(error) => {
1683                return GatewayCommandPipelineReport {
1684                    error: Some(error),
1685                    ..GatewayCommandPipelineReport::default()
1686                };
1687            }
1688        };
1689
1690        self.dispatch_command_frame(gateway, deployment, command_frame, now)
1691    }
1692
1693    fn decode_command_frame(
1694        &mut self,
1695        input: &[u8],
1696    ) -> Result<CommandFrame, GatewayCommandPipelineError> {
1697        let frame = match self.decoder.decode(input) {
1698            Ok(frame) => frame,
1699            Err(error) => {
1700                self.stats.frames_rejected_decode =
1701                    self.stats.frames_rejected_decode.saturating_add(1);
1702                return Err(GatewayCommandPipelineError::Decode(error));
1703            }
1704        };
1705
1706        let RuntimeFrame::Command(command_frame) = frame else {
1707            self.stats.frames_rejected_non_command =
1708                self.stats.frames_rejected_non_command.saturating_add(1);
1709            return Err(GatewayCommandPipelineError::NonCommandFrame);
1710        };
1711        self.stats.command_frames_decoded = self.stats.command_frames_decoded.saturating_add(1);
1712
1713        Ok(command_frame)
1714    }
1715
1716    fn process_command_frame(
1717        &mut self,
1718        gateway: &mut GatewaySessionTable,
1719        station_queues: &mut BTreeMap<StationId, CommandQueues>,
1720        command_frame: CommandFrame,
1721        now: Tick,
1722        ingress: CommandIngress,
1723    ) -> GatewayCommandPipelineReport {
1724        let client_id = command_frame.client_id;
1725        let command_id = command_frame.command_id;
1726        let command = command_frame.into_envelope(now);
1727        let admission = match gateway.admit_command(&command) {
1728            Ok(admission) => {
1729                self.stats.commands_admitted = self.stats.commands_admitted.saturating_add(1);
1730                admission
1731            }
1732            Err(error) => {
1733                self.stats.commands_rejected_gateway =
1734                    self.stats.commands_rejected_gateway.saturating_add(1);
1735                return self.rejected_report(
1736                    client_id,
1737                    command_id,
1738                    None,
1739                    now,
1740                    gateway_reject_reason_code(error),
1741                    GatewayCommandPipelineError::Gateway(error),
1742                );
1743            }
1744        };
1745
1746        let station_id = admission.route.station_id;
1747        let Some(queue) = station_queues.get_mut(&station_id) else {
1748            self.stats.commands_rejected_queue =
1749                self.stats.commands_rejected_queue.saturating_add(1);
1750            return self.rejected_report(
1751                client_id,
1752                command_id,
1753                Some(station_id),
1754                now,
1755                GATEWAY_COMMAND_ACK_MISSING_QUEUE,
1756                GatewayCommandPipelineError::MissingQueue(station_id),
1757            );
1758        };
1759
1760        if let Err(error) = queue.push(command, ingress) {
1761            self.stats.commands_rejected_queue =
1762                self.stats.commands_rejected_queue.saturating_add(1);
1763            return self.rejected_report(
1764                client_id,
1765                command_id,
1766                Some(station_id),
1767                now,
1768                queue_reject_reason_code(error),
1769                GatewayCommandPipelineError::Queue(error),
1770            );
1771        }
1772
1773        self.stats.commands_enqueued = self.stats.commands_enqueued.saturating_add(1);
1774        self.accepted_report(client_id, command_id, station_id, now)
1775    }
1776
1777    fn dispatch_command_frame(
1778        &mut self,
1779        gateway: &mut GatewaySessionTable,
1780        deployment: &DeploymentRouteTable,
1781        command_frame: CommandFrame,
1782        now: Tick,
1783    ) -> GatewayCommandPipelineReport {
1784        let client_id = command_frame.client_id;
1785        let command_id = command_frame.command_id;
1786        let command = command_frame.into_envelope(now);
1787        let admission = match gateway.admit_command(&command) {
1788            Ok(admission) => {
1789                self.stats.commands_admitted = self.stats.commands_admitted.saturating_add(1);
1790                admission
1791            }
1792            Err(error) => {
1793                self.stats.commands_rejected_gateway =
1794                    self.stats.commands_rejected_gateway.saturating_add(1);
1795                return self.rejected_report(
1796                    client_id,
1797                    command_id,
1798                    None,
1799                    now,
1800                    gateway_reject_reason_code(error),
1801                    GatewayCommandPipelineError::Gateway(error),
1802                );
1803            }
1804        };
1805
1806        let delivery = match deployment.resolve_gateway_route(admission.route) {
1807            Ok(delivery) => {
1808                self.stats.commands_routed_deployment =
1809                    self.stats.commands_routed_deployment.saturating_add(1);
1810                delivery
1811            }
1812            Err(error) => {
1813                self.stats.commands_rejected_deployment =
1814                    self.stats.commands_rejected_deployment.saturating_add(1);
1815                return self.rejected_report(
1816                    client_id,
1817                    command_id,
1818                    Some(admission.route.station_id),
1819                    now,
1820                    GATEWAY_COMMAND_ACK_DEPLOYMENT_REJECTED,
1821                    GatewayCommandPipelineError::Deployment(error),
1822                );
1823            }
1824        };
1825
1826        self.dispatch_report(command, delivery, now)
1827    }
1828
1829    fn accepted_report(
1830        &mut self,
1831        client_id: ClientId,
1832        command_id: CommandId,
1833        station_id: StationId,
1834        now: Tick,
1835    ) -> GatewayCommandPipelineReport {
1836        let ack = CommandAckFrame {
1837            client_id,
1838            command_id,
1839            server_tick: now,
1840            accepted: true,
1841            reason_code: GATEWAY_COMMAND_ACK_ACCEPTED,
1842        };
1843        match self.encode_ack(&ack) {
1844            Ok(ack_bytes) => GatewayCommandPipelineReport {
1845                client_id: Some(client_id),
1846                command_id: Some(command_id),
1847                station_id: Some(station_id),
1848                node_id: None,
1849                delivery: None,
1850                command: None,
1851                accepted: true,
1852                reason_code: GATEWAY_COMMAND_ACK_ACCEPTED,
1853                ack_bytes: Some(ack_bytes),
1854                error: None,
1855            },
1856            Err(error) => GatewayCommandPipelineReport {
1857                client_id: Some(client_id),
1858                command_id: Some(command_id),
1859                station_id: Some(station_id),
1860                node_id: None,
1861                delivery: None,
1862                command: None,
1863                accepted: false,
1864                reason_code: GATEWAY_COMMAND_ACK_ACCEPTED,
1865                ack_bytes: None,
1866                error: Some(GatewayCommandPipelineError::Encode(error)),
1867            },
1868        }
1869    }
1870
1871    fn dispatch_report(
1872        &mut self,
1873        command: CommandEnvelope,
1874        delivery: GatewayDeliveryRoute,
1875        now: Tick,
1876    ) -> GatewayCommandPipelineReport {
1877        let ack = CommandAckFrame {
1878            client_id: command.client_id,
1879            command_id: command.id,
1880            server_tick: now,
1881            accepted: true,
1882            reason_code: GATEWAY_COMMAND_ACK_ACCEPTED,
1883        };
1884        match self.encode_ack(&ack) {
1885            Ok(ack_bytes) => GatewayCommandPipelineReport {
1886                client_id: Some(command.client_id),
1887                command_id: Some(command.id),
1888                station_id: Some(delivery.station_id),
1889                node_id: Some(delivery.node_id),
1890                delivery: Some(delivery),
1891                command: Some(command),
1892                accepted: true,
1893                reason_code: GATEWAY_COMMAND_ACK_ACCEPTED,
1894                ack_bytes: Some(ack_bytes),
1895                error: None,
1896            },
1897            Err(error) => GatewayCommandPipelineReport {
1898                client_id: Some(command.client_id),
1899                command_id: Some(command.id),
1900                station_id: Some(delivery.station_id),
1901                node_id: Some(delivery.node_id),
1902                delivery: Some(delivery),
1903                command: None,
1904                accepted: false,
1905                reason_code: GATEWAY_COMMAND_ACK_ACCEPTED,
1906                ack_bytes: None,
1907                error: Some(GatewayCommandPipelineError::Encode(error)),
1908            },
1909        }
1910    }
1911
1912    fn rejected_report(
1913        &mut self,
1914        client_id: ClientId,
1915        command_id: CommandId,
1916        station_id: Option<StationId>,
1917        now: Tick,
1918        reason_code: u16,
1919        error: GatewayCommandPipelineError,
1920    ) -> GatewayCommandPipelineReport {
1921        let ack_bytes = if self.config.ack_rejections {
1922            let ack = CommandAckFrame {
1923                client_id,
1924                command_id,
1925                server_tick: now,
1926                accepted: false,
1927                reason_code,
1928            };
1929            match self.encode_ack(&ack) {
1930                Ok(bytes) => Some(bytes),
1931                Err(encode_error) => {
1932                    return GatewayCommandPipelineReport {
1933                        client_id: Some(client_id),
1934                        command_id: Some(command_id),
1935                        station_id,
1936                        node_id: None,
1937                        delivery: None,
1938                        command: None,
1939                        accepted: false,
1940                        reason_code,
1941                        ack_bytes: None,
1942                        error: Some(GatewayCommandPipelineError::Encode(encode_error)),
1943                    };
1944                }
1945            }
1946        } else {
1947            None
1948        };
1949
1950        GatewayCommandPipelineReport {
1951            client_id: Some(client_id),
1952            command_id: Some(command_id),
1953            station_id,
1954            node_id: None,
1955            delivery: None,
1956            command: None,
1957            accepted: false,
1958            reason_code,
1959            ack_bytes,
1960            error: Some(error),
1961        }
1962    }
1963
1964    fn encode_ack(&mut self, ack: &CommandAckFrame) -> Result<Vec<u8>, BinaryEncodeError> {
1965        let mut out = Vec::new();
1966        self.encoder.encode_command_ack(ack, &mut out)?;
1967        self.stats.acks_encoded = self.stats.acks_encoded.saturating_add(1);
1968        Ok(out)
1969    }
1970}
1971
1972impl Default for GatewayCommandPipeline {
1973    fn default() -> Self {
1974        Self::new(GatewayCommandPipelineConfig::default())
1975    }
1976}
1977
1978const fn gateway_reject_reason_code(error: GatewayError) -> u16 {
1979    match error {
1980        GatewayError::ReplayOrStale { .. } => GATEWAY_COMMAND_ACK_REPLAY_OR_STALE,
1981        GatewayError::RateLimited { .. } => GATEWAY_COMMAND_ACK_RATE_LIMITED,
1982        GatewayError::MissingSession(_)
1983        | GatewayError::SessionDisconnected { .. }
1984        | GatewayError::BadGeneration { .. }
1985        | GatewayError::CapacityFull { .. } => GATEWAY_COMMAND_ACK_GATEWAY_REJECTED,
1986    }
1987}
1988
1989const fn queue_reject_reason_code(error: CommandQueueError) -> u16 {
1990    match error {
1991        CommandQueueError::QueueFull(_) => GATEWAY_COMMAND_ACK_QUEUE_FULL,
1992        CommandQueueError::RejectedByBarrier(_) => GATEWAY_COMMAND_ACK_BARRIER_REJECTED,
1993    }
1994}
1995
1996/// Gateway-side client command transport bridge statistics.
1997#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1998pub struct GatewayClientTransportStats {
1999    /// Client packets consumed from transport.
2000    pub packets_received: usize,
2001    /// Client packet bytes consumed from transport.
2002    pub bytes_received: usize,
2003    /// Command frames decoded from client packets.
2004    pub command_frames_received: usize,
2005    /// Packets rejected because transport source and command client differed.
2006    pub source_mismatches: usize,
2007    /// Commands accepted by the gateway command pipeline.
2008    pub commands_accepted: usize,
2009    /// Commands rejected by the gateway command pipeline.
2010    pub commands_rejected: usize,
2011    /// Command ACK frames submitted to client transport.
2012    pub acks_sent: usize,
2013    /// Command ACK bytes submitted to client transport.
2014    pub ack_bytes_sent: usize,
2015}
2016
2017/// Result of pumping gateway-side client command packets.
2018#[derive(Clone, Debug, Default, PartialEq, Eq)]
2019pub struct GatewayClientTransportPump {
2020    /// Client packets consumed from transport.
2021    pub packets_received: usize,
2022    /// Client packet bytes consumed from transport.
2023    pub bytes_received: usize,
2024    /// Gateway pipeline reports produced from accepted or rejected commands.
2025    pub reports: Vec<GatewayCommandPipelineReport>,
2026    /// Command ACK frames submitted to client transport.
2027    pub acks_sent: usize,
2028    /// Command ACK bytes submitted to client transport.
2029    pub ack_bytes_sent: usize,
2030}
2031
2032impl GatewayClientTransportPump {
2033    /// Returns processed command count.
2034    pub fn commands_processed(&self) -> usize {
2035        self.reports.len()
2036    }
2037
2038    /// Returns accepted command count.
2039    pub fn commands_accepted(&self) -> usize {
2040        self.reports.iter().filter(|report| report.accepted).count()
2041    }
2042
2043    /// Returns rejected command count.
2044    pub fn commands_rejected(&self) -> usize {
2045        self.reports
2046            .iter()
2047            .filter(|report| !report.accepted)
2048            .count()
2049    }
2050}
2051
2052/// Compact result of pumping gateway commands without retaining per-command reports.
2053#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2054pub struct GatewayClientTransportSummary {
2055    /// Client packets consumed from transport.
2056    pub packets_received: usize,
2057    /// Client packet bytes consumed from transport.
2058    pub bytes_received: usize,
2059    /// Commands accepted by the gateway pipeline.
2060    pub commands_accepted: usize,
2061    /// Commands rejected by the gateway pipeline.
2062    pub commands_rejected: usize,
2063    /// Command ACK frames submitted to client transport.
2064    pub acks_sent: usize,
2065    /// Command ACK bytes submitted to client transport.
2066    pub ack_bytes_sent: usize,
2067}
2068
2069/// Error produced while pumping gateway-side client command packets.
2070#[derive(Clone, Debug, PartialEq, Eq)]
2071pub enum GatewayClientTransportError<E> {
2072    /// Underlying client transport failed while receiving or sending acknowledgements.
2073    Transport(E),
2074    /// Wire decoding failed.
2075    Decode(BinaryDecodeError),
2076    /// Packet decoded as a non-command frame.
2077    NonCommandFrame,
2078    /// Transport source client and command frame client disagreed.
2079    SourceMismatch {
2080        /// Client identified by the transport.
2081        packet_client_id: ClientId,
2082        /// Client encoded inside the command frame.
2083        frame_client_id: ClientId,
2084    },
2085}
2086
2087impl<E: core::fmt::Display> core::fmt::Display for GatewayClientTransportError<E> {
2088    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2089        match self {
2090            Self::Transport(error) => write!(f, "{error}"),
2091            Self::Decode(error) => write!(f, "{error}"),
2092            Self::NonCommandFrame => f.write_str("gateway client transport expected command frame"),
2093            Self::SourceMismatch {
2094                packet_client_id,
2095                frame_client_id,
2096            } => write!(
2097                f,
2098                "gateway client source mismatch: packet {}, frame {}",
2099                packet_client_id.get(),
2100                frame_client_id.get()
2101            ),
2102        }
2103    }
2104}
2105
2106impl<E> std::error::Error for GatewayClientTransportError<E>
2107where
2108    E: std::error::Error + 'static,
2109{
2110    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2111        match self {
2112            Self::Transport(error) => Some(error),
2113            Self::Decode(error) => Some(error),
2114            Self::NonCommandFrame | Self::SourceMismatch { .. } => None,
2115        }
2116    }
2117}
2118
2119/// Low-level bridge from client packet transport into the gateway command pipeline.
2120#[derive(Clone, Debug, Default)]
2121pub struct GatewayClientTransportBridge {
2122    stats: GatewayClientTransportStats,
2123}
2124
2125impl GatewayClientTransportBridge {
2126    /// Creates a gateway client transport bridge.
2127    pub const fn new() -> Self {
2128        Self {
2129            stats: GatewayClientTransportStats {
2130                packets_received: 0,
2131                bytes_received: 0,
2132                command_frames_received: 0,
2133                source_mismatches: 0,
2134                commands_accepted: 0,
2135                commands_rejected: 0,
2136                acks_sent: 0,
2137                ack_bytes_sent: 0,
2138            },
2139        }
2140    }
2141
2142    /// Returns accumulated statistics.
2143    pub const fn stats(&self) -> GatewayClientTransportStats {
2144        self.stats
2145    }
2146
2147    /// Pumps up to `max_packets` client command packets into station queues and
2148    /// sends produced ACKs back through the same bounded client transport.
2149    #[allow(clippy::too_many_arguments)]
2150    pub fn pump_ingress<T, E>(
2151        &mut self,
2152        transport: &mut T,
2153        pipeline: &mut GatewayCommandPipeline,
2154        gateway: &mut GatewaySessionTable,
2155        station_queues: &mut BTreeMap<StationId, CommandQueues>,
2156        now: Tick,
2157        ingress: CommandIngress,
2158        max_packets: usize,
2159    ) -> Result<GatewayClientTransportPump, GatewayClientTransportError<E>>
2160    where
2161        T: TransportReceiver<Error = E> + TransportSink<Error = E>,
2162    {
2163        let mut pump = GatewayClientTransportPump::default();
2164        for _ in 0..max_packets {
2165            let Some(packet) = transport
2166                .try_recv()
2167                .map_err(GatewayClientTransportError::Transport)?
2168            else {
2169                break;
2170            };
2171            let (ack_client_id, packet_bytes, report) = self.process_ingress_packet::<E>(
2172                pipeline,
2173                gateway,
2174                station_queues,
2175                &packet,
2176                now,
2177                ingress,
2178            )?;
2179            pump.packets_received = pump.packets_received.saturating_add(1);
2180            pump.bytes_received = pump.bytes_received.saturating_add(packet_bytes);
2181
2182            if let Some(bytes) = &report.ack_bytes {
2183                let ack_len = bytes.len();
2184                transport
2185                    .send(OutboundPacket {
2186                        client_id: ack_client_id,
2187                        bytes: bytes.clone(),
2188                    })
2189                    .map_err(GatewayClientTransportError::Transport)?;
2190                self.stats.acks_sent = self.stats.acks_sent.saturating_add(1);
2191                self.stats.ack_bytes_sent = self.stats.ack_bytes_sent.saturating_add(ack_len);
2192                pump.acks_sent = pump.acks_sent.saturating_add(1);
2193                pump.ack_bytes_sent = pump.ack_bytes_sent.saturating_add(ack_len);
2194            }
2195            pump.reports.push(report);
2196        }
2197        Ok(pump)
2198    }
2199
2200    /// Pumps commands and moves ACK buffers directly into transport without
2201    /// retaining per-command pipeline reports.
2202    ///
2203    /// Use [`Self::pump_ingress`] when the caller needs detailed error reports
2204    /// or encoded ACK bytes after sending.
2205    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2206    pub fn pump_ingress_compact<T, E>(
2207        &mut self,
2208        transport: &mut T,
2209        pipeline: &mut GatewayCommandPipeline,
2210        gateway: &mut GatewaySessionTable,
2211        station_queues: &mut BTreeMap<StationId, CommandQueues>,
2212        now: Tick,
2213        ingress: CommandIngress,
2214        max_packets: usize,
2215    ) -> Result<GatewayClientTransportSummary, GatewayClientTransportError<E>>
2216    where
2217        T: TransportReceiver<Error = E> + TransportSink<Error = E>,
2218    {
2219        let mut summary = GatewayClientTransportSummary::default();
2220        for _ in 0..max_packets {
2221            let Some(packet) = transport
2222                .try_recv()
2223                .map_err(GatewayClientTransportError::Transport)?
2224            else {
2225                break;
2226            };
2227            let (ack_client_id, packet_bytes, mut report) = self.process_ingress_packet::<E>(
2228                pipeline,
2229                gateway,
2230                station_queues,
2231                &packet,
2232                now,
2233                ingress,
2234            )?;
2235            summary.packets_received = summary.packets_received.saturating_add(1);
2236            summary.bytes_received = summary.bytes_received.saturating_add(packet_bytes);
2237            if report.accepted {
2238                summary.commands_accepted = summary.commands_accepted.saturating_add(1);
2239            } else {
2240                summary.commands_rejected = summary.commands_rejected.saturating_add(1);
2241            }
2242
2243            if let Some(bytes) = report.ack_bytes.take() {
2244                let ack_len = bytes.len();
2245                transport
2246                    .send(OutboundPacket {
2247                        client_id: ack_client_id,
2248                        bytes,
2249                    })
2250                    .map_err(GatewayClientTransportError::Transport)?;
2251                self.stats.acks_sent = self.stats.acks_sent.saturating_add(1);
2252                self.stats.ack_bytes_sent = self.stats.ack_bytes_sent.saturating_add(ack_len);
2253                summary.acks_sent = summary.acks_sent.saturating_add(1);
2254                summary.ack_bytes_sent = summary.ack_bytes_sent.saturating_add(ack_len);
2255            }
2256        }
2257        Ok(summary)
2258    }
2259
2260    #[allow(clippy::too_many_arguments)]
2261    fn process_ingress_packet<E>(
2262        &mut self,
2263        pipeline: &mut GatewayCommandPipeline,
2264        gateway: &mut GatewaySessionTable,
2265        station_queues: &mut BTreeMap<StationId, CommandQueues>,
2266        packet: &InboundPacket,
2267        now: Tick,
2268        ingress: CommandIngress,
2269    ) -> Result<(ClientId, usize, GatewayCommandPipelineReport), GatewayClientTransportError<E>>
2270    {
2271        let packet_bytes = packet.bytes.len();
2272        self.stats.packets_received = self.stats.packets_received.saturating_add(1);
2273        self.stats.bytes_received = self.stats.bytes_received.saturating_add(packet_bytes);
2274        let command_frame = match pipeline.decode_command_frame(&packet.bytes) {
2275            Ok(command_frame) => command_frame,
2276            Err(GatewayCommandPipelineError::Decode(error)) => {
2277                return Err(GatewayClientTransportError::Decode(error));
2278            }
2279            Err(GatewayCommandPipelineError::NonCommandFrame) => {
2280                return Err(GatewayClientTransportError::NonCommandFrame);
2281            }
2282            Err(error) => {
2283                unreachable!(
2284                    "decode_command_frame only returns decode/non-command errors: {error}"
2285                );
2286            }
2287        };
2288        self.stats.command_frames_received = self.stats.command_frames_received.saturating_add(1);
2289        if let Some(packet_client_id) = packet.client_id
2290            && packet_client_id != command_frame.client_id
2291        {
2292            self.stats.source_mismatches = self.stats.source_mismatches.saturating_add(1);
2293            return Err(GatewayClientTransportError::SourceMismatch {
2294                packet_client_id,
2295                frame_client_id: command_frame.client_id,
2296            });
2297        }
2298        let ack_client_id = command_frame.client_id;
2299        let report =
2300            pipeline.process_command_frame(gateway, station_queues, command_frame, now, ingress);
2301        if report.accepted {
2302            self.stats.commands_accepted = self.stats.commands_accepted.saturating_add(1);
2303        } else {
2304            self.stats.commands_rejected = self.stats.commands_rejected.saturating_add(1);
2305        }
2306        Ok((ack_client_id, packet_bytes, report))
2307    }
2308}
2309
2310// Linear scans win for small registries; larger sets amortize an ID index.
2311const STATION_LOOKUP_INDEX_THRESHOLD: usize = 64;
2312
2313/// Small ordered in-process Station collection for simulations and embedders.
2314#[derive(Clone, Debug, Default)]
2315pub struct StationSet {
2316    stations: Vec<Station>,
2317    positions: HashMap<StationId, usize>,
2318}
2319
2320impl StationSet {
2321    /// Creates an empty collection with capacity for `capacity` Stations.
2322    pub fn with_capacity(capacity: usize) -> Self {
2323        Self {
2324            stations: Vec::with_capacity(capacity),
2325            positions: if capacity >= STATION_LOOKUP_INDEX_THRESHOLD {
2326                HashMap::with_capacity(capacity)
2327            } else {
2328                HashMap::new()
2329            },
2330        }
2331    }
2332
2333    /// Reserves capacity for at least `additional` more Stations and lookup entries.
2334    pub fn reserve(&mut self, additional: usize) {
2335        self.stations.reserve(additional);
2336        if !self.positions.is_empty() {
2337            self.positions.reserve(additional);
2338        } else if self.stations.len().saturating_add(additional) >= STATION_LOOKUP_INDEX_THRESHOLD {
2339            self.positions.reserve(self.stations.len() + additional);
2340        }
2341    }
2342
2343    /// Adds a station to the collection.
2344    pub fn push(&mut self, station: Station) {
2345        let station_id = station.config().station_id;
2346        self.activate_lookup_for(self.stations.len().saturating_add(1));
2347        if !self.positions.is_empty() {
2348            self.positions
2349                .entry(station_id)
2350                .or_insert(self.stations.len());
2351        }
2352        self.stations.push(station);
2353    }
2354
2355    /// Removes and returns the first Station with `station_id`.
2356    ///
2357    /// Remaining Stations retain their iteration order. Lookup storage remains
2358    /// allocated for reuse when the indexed path is active.
2359    pub fn remove(&mut self, station_id: StationId) -> Option<Station> {
2360        let position = self.position(station_id)?;
2361        let station = self.stations.remove(position);
2362        self.rebuild_positions();
2363        Some(station)
2364    }
2365
2366    /// Gets a station by id.
2367    pub fn get(&self, station_id: StationId) -> Option<&Station> {
2368        self.position(station_id)
2369            .and_then(|index| self.stations.get(index))
2370    }
2371
2372    /// Gets a mutable station by id.
2373    pub fn get_mut(&mut self, station_id: StationId) -> Option<&mut Station> {
2374        let index = self.position(station_id)?;
2375        self.stations.get_mut(index)
2376    }
2377
2378    /// Gets two distinct mutable stations by id.
2379    pub fn get_pair_mut(
2380        &mut self,
2381        left_id: StationId,
2382        right_id: StationId,
2383    ) -> Option<(&mut Station, &mut Station)> {
2384        if left_id == right_id {
2385            return None;
2386        }
2387
2388        let left_index = self.position(left_id)?;
2389        let right_index = self.position(right_id)?;
2390
2391        if left_index < right_index {
2392            let (left, right) = self.stations.split_at_mut(right_index);
2393            Some((&mut left[left_index], &mut right[0]))
2394        } else {
2395            let (left, right) = self.stations.split_at_mut(left_index);
2396            Some((&mut right[0], &mut left[right_index]))
2397        }
2398    }
2399
2400    /// Iterates over stations.
2401    pub fn iter(&self) -> impl Iterator<Item = &Station> {
2402        self.stations.iter()
2403    }
2404
2405    /// Iterates mutably over stations.
2406    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Station> {
2407        self.stations.iter_mut()
2408    }
2409
2410    /// Returns station ids matching a barrier scope.
2411    pub fn station_ids_in_scope(&self, scope: BarrierScope) -> Vec<StationId> {
2412        self.stations
2413            .iter()
2414            .filter(|station| match scope {
2415                BarrierScope::Instance(instance_id) => station.config().instance_id == instance_id,
2416                BarrierScope::Station(station_id) => station.config().station_id == station_id,
2417            })
2418            .map(|station| station.config().station_id)
2419            .collect()
2420    }
2421
2422    /// Number of stations.
2423    pub fn len(&self) -> usize {
2424        self.stations.len()
2425    }
2426
2427    /// Station slots retained without growing the ordered collection.
2428    pub fn station_capacity(&self) -> usize {
2429        self.stations.capacity()
2430    }
2431
2432    /// Lookup entries retained without rehashing the Station index.
2433    pub fn lookup_capacity(&self) -> usize {
2434        self.positions.capacity()
2435    }
2436
2437    /// Returns whether Station lookup currently uses the indexed path.
2438    pub fn lookup_index_active(&self) -> bool {
2439        !self.positions.is_empty()
2440    }
2441
2442    /// Returns whether no stations are registered.
2443    pub fn is_empty(&self) -> bool {
2444        self.stations.is_empty()
2445    }
2446
2447    fn position(&self, station_id: StationId) -> Option<usize> {
2448        if self.positions.is_empty() {
2449            self.stations
2450                .iter()
2451                .position(|station| station.config().station_id == station_id)
2452        } else {
2453            self.positions.get(&station_id).copied()
2454        }
2455    }
2456
2457    fn activate_lookup_for(&mut self, new_len: usize) {
2458        if new_len < STATION_LOOKUP_INDEX_THRESHOLD || !self.positions.is_empty() {
2459            return;
2460        }
2461        self.positions.reserve(new_len);
2462        for (index, station) in self.stations.iter().enumerate() {
2463            self.positions
2464                .entry(station.config().station_id)
2465                .or_insert(index);
2466        }
2467    }
2468
2469    fn rebuild_positions(&mut self) {
2470        if self.positions.is_empty() {
2471            return;
2472        }
2473        self.positions.clear();
2474        for (index, station) in self.stations.iter().enumerate() {
2475            self.positions
2476                .entry(station.config().station_id)
2477                .or_insert(index);
2478        }
2479    }
2480}
2481
2482/// Station-local spatial indexes keyed by station id.
2483#[derive(Clone, Debug, Default)]
2484pub struct StationIndexSet {
2485    indexes: Vec<(StationId, CellIndex)>,
2486    positions: HashMap<StationId, usize>,
2487}
2488
2489impl StationIndexSet {
2490    /// Creates an empty collection with capacity for `capacity` Station indexes.
2491    pub fn with_capacity(capacity: usize) -> Self {
2492        Self {
2493            indexes: Vec::with_capacity(capacity),
2494            positions: if capacity >= STATION_LOOKUP_INDEX_THRESHOLD {
2495                HashMap::with_capacity(capacity)
2496            } else {
2497                HashMap::new()
2498            },
2499        }
2500    }
2501
2502    /// Reserves capacity for at least `additional` more indexes and lookup entries.
2503    pub fn reserve(&mut self, additional: usize) {
2504        self.indexes.reserve(additional);
2505        if !self.positions.is_empty() {
2506            self.positions.reserve(additional);
2507        } else if self.indexes.len().saturating_add(additional) >= STATION_LOOKUP_INDEX_THRESHOLD {
2508            self.positions.reserve(self.indexes.len() + additional);
2509        }
2510    }
2511
2512    /// Adds or replaces one station index.
2513    pub fn insert(&mut self, station_id: StationId, index: CellIndex) {
2514        if let Some(position) = self.position(station_id) {
2515            self.indexes[position].1 = index;
2516        } else {
2517            self.activate_lookup_for(self.indexes.len().saturating_add(1));
2518            if !self.positions.is_empty() {
2519                self.positions.insert(station_id, self.indexes.len());
2520            }
2521            self.indexes.push((station_id, index));
2522        }
2523    }
2524
2525    /// Removes and returns one Station-local spatial index.
2526    ///
2527    /// Remaining indexes retain their iteration order and indexed lookup
2528    /// storage remains allocated for later registrations.
2529    pub fn remove(&mut self, station_id: StationId) -> Option<CellIndex> {
2530        let position = self.position(station_id)?;
2531        let (_, index) = self.indexes.remove(position);
2532        self.rebuild_positions();
2533        Some(index)
2534    }
2535
2536    /// Gets one station index.
2537    pub fn get(&self, station_id: StationId) -> Option<&CellIndex> {
2538        self.position(station_id)
2539            .and_then(|position| self.indexes.get(position))
2540            .map(|(_, index)| index)
2541    }
2542
2543    /// Gets one mutable station index.
2544    pub fn get_mut(&mut self, station_id: StationId) -> Option<&mut CellIndex> {
2545        let position = self.position(station_id)?;
2546        self.indexes.get_mut(position).map(|(_, index)| index)
2547    }
2548
2549    /// Gets two distinct mutable station indexes.
2550    pub fn get_pair_mut(
2551        &mut self,
2552        left_id: StationId,
2553        right_id: StationId,
2554    ) -> Option<(&mut CellIndex, &mut CellIndex)> {
2555        if left_id == right_id {
2556            return None;
2557        }
2558
2559        let left_index = self.position(left_id)?;
2560        let right_index = self.position(right_id)?;
2561
2562        if left_index < right_index {
2563            let (left, right) = self.indexes.split_at_mut(right_index);
2564            Some((&mut left[left_index].1, &mut right[0].1))
2565        } else {
2566            let (left, right) = self.indexes.split_at_mut(left_index);
2567            Some((&mut right[0].1, &mut left[right_index].1))
2568        }
2569    }
2570
2571    /// Number of indexes.
2572    pub fn len(&self) -> usize {
2573        self.indexes.len()
2574    }
2575
2576    /// Index slots retained without growing the ordered collection.
2577    pub fn index_capacity(&self) -> usize {
2578        self.indexes.capacity()
2579    }
2580
2581    /// Lookup entries retained without rehashing the Station index.
2582    pub fn lookup_capacity(&self) -> usize {
2583        self.positions.capacity()
2584    }
2585
2586    /// Returns whether Station-index lookup currently uses the indexed path.
2587    pub fn lookup_index_active(&self) -> bool {
2588        !self.positions.is_empty()
2589    }
2590
2591    /// Iterates over registered indexes.
2592    pub fn iter(&self) -> impl Iterator<Item = (StationId, &CellIndex)> {
2593        self.indexes
2594            .iter()
2595            .map(|(station_id, index)| (*station_id, index))
2596    }
2597
2598    /// Returns whether no indexes are registered.
2599    pub fn is_empty(&self) -> bool {
2600        self.indexes.is_empty()
2601    }
2602
2603    fn position(&self, station_id: StationId) -> Option<usize> {
2604        if self.positions.is_empty() {
2605            self.indexes.iter().position(|(id, _)| *id == station_id)
2606        } else {
2607            self.positions.get(&station_id).copied()
2608        }
2609    }
2610
2611    fn activate_lookup_for(&mut self, new_len: usize) {
2612        if new_len < STATION_LOOKUP_INDEX_THRESHOLD || !self.positions.is_empty() {
2613            return;
2614        }
2615        self.positions.reserve(new_len);
2616        for (index, (station_id, _)) in self.indexes.iter().enumerate() {
2617            self.positions.entry(*station_id).or_insert(index);
2618        }
2619    }
2620
2621    fn rebuild_positions(&mut self) {
2622        if self.positions.is_empty() {
2623            return;
2624        }
2625        self.positions.clear();
2626        for (index, (station_id, _)) in self.indexes.iter().enumerate() {
2627            self.positions.entry(*station_id).or_insert(index);
2628        }
2629    }
2630}
2631
2632/// Lightweight coefficients used to derive hotspot/scheduler load samples.
2633#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2634pub struct StationLoadSamplerConfig {
2635    /// Estimated bytes contributed by one stored entity in the measurement window.
2636    pub estimated_bytes_per_entity: usize,
2637    /// Estimated bytes contributed by one subscriber routed to a station.
2638    pub estimated_bytes_per_subscriber: usize,
2639    /// Estimated bytes contributed by one queued station event.
2640    pub estimated_bytes_per_event: usize,
2641    /// Runtime cost units assigned to one authoritative entity.
2642    pub tick_cost_per_owned_entity: u64,
2643    /// Runtime cost units assigned to one read-only ghost entity.
2644    pub tick_cost_per_ghost_entity: u64,
2645    /// Runtime cost units assigned to one occupied spatial cell.
2646    pub tick_cost_per_occupied_cell: u64,
2647    /// Runtime cost units assigned to one queued station event.
2648    pub tick_cost_per_queued_event: u64,
2649}
2650
2651impl Default for StationLoadSamplerConfig {
2652    fn default() -> Self {
2653        Self {
2654            estimated_bytes_per_entity: 48,
2655            estimated_bytes_per_subscriber: 16,
2656            estimated_bytes_per_event: 32,
2657            tick_cost_per_owned_entity: 2,
2658            tick_cost_per_ghost_entity: 1,
2659            tick_cost_per_occupied_cell: 1,
2660            tick_cost_per_queued_event: 1,
2661        }
2662    }
2663}
2664
2665/// Runtime helper that derives `StationLoadSample` from existing low-level state.
2666#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2667pub struct StationLoadSampler {
2668    config: StationLoadSamplerConfig,
2669}
2670
2671/// Caller-owned reusable storage for periodic station load sampling.
2672#[derive(Clone, Debug, Default)]
2673pub struct StationLoadSamplerScratch {
2674    subscribers_by_station: HashMap<StationId, usize>,
2675    occupancy: Vec<CellOccupancy>,
2676    samples: Vec<StationLoadSample>,
2677}
2678
2679impl StationLoadSamplerScratch {
2680    /// Creates empty sampling storage.
2681    pub fn new() -> Self {
2682        Self::default()
2683    }
2684
2685    /// Hash-table capacity retained for subscriber aggregation.
2686    pub fn retained_subscriber_capacity(&self) -> usize {
2687        self.subscribers_by_station.capacity()
2688    }
2689
2690    /// Temporary occupancy capacity retained across station scans.
2691    pub fn retained_occupancy_capacity(&self) -> usize {
2692        self.occupancy.capacity()
2693    }
2694
2695    /// Station output slots retained across sampling passes.
2696    pub fn retained_sample_slots(&self) -> usize {
2697        self.samples.len()
2698    }
2699
2700    /// Total cell output capacity retained across station slots.
2701    pub fn retained_cell_capacity(&self) -> usize {
2702        self.samples
2703            .iter()
2704            .map(|sample| sample.cells.capacity())
2705            .sum()
2706    }
2707}
2708
2709impl StationLoadSampler {
2710    /// Creates a station load sampler.
2711    pub const fn new(config: StationLoadSamplerConfig) -> Self {
2712        Self { config }
2713    }
2714
2715    /// Returns the sampler configuration.
2716    pub const fn config(&self) -> StationLoadSamplerConfig {
2717        self.config
2718    }
2719
2720    /// Samples every station into caller-owned storage and returns its active prefix.
2721    ///
2722    /// Reusing the same scratch retains subscriber, occupancy, station, and per-cell storage.
2723    pub fn sample_all_into<'a>(
2724        &self,
2725        stations: &StationSet,
2726        indexes: &StationIndexSet,
2727        router: &EventRouter,
2728        subscriber_counts: &[(StationId, usize)],
2729        scratch: &'a mut StationLoadSamplerScratch,
2730    ) -> &'a [StationLoadSample] {
2731        scratch.subscribers_by_station.clear();
2732        for (station_id, count) in subscriber_counts {
2733            let entry = scratch
2734                .subscribers_by_station
2735                .entry(*station_id)
2736                .or_insert(0);
2737            *entry = entry.saturating_add(*count);
2738        }
2739
2740        let station_count = stations.len();
2741        if scratch.samples.len() < station_count {
2742            scratch
2743                .samples
2744                .resize_with(station_count, StationLoadSample::default);
2745        }
2746        for (station, sample) in stations
2747            .iter()
2748            .zip(scratch.samples[..station_count].iter_mut())
2749        {
2750            let station_id = station.config().station_id;
2751            self.sample_station_into(
2752                station,
2753                indexes.get(station_id),
2754                router.queued_len(station_id).unwrap_or(0),
2755                scratch
2756                    .subscribers_by_station
2757                    .get(&station_id)
2758                    .copied()
2759                    .unwrap_or(0),
2760                &mut scratch.occupancy,
2761                sample,
2762            );
2763        }
2764        &scratch.samples[..station_count]
2765    }
2766
2767    fn sample_station_into(
2768        &self,
2769        station: &Station,
2770        index: Option<&CellIndex>,
2771        queued_events: usize,
2772        subscribers: usize,
2773        occupancy: &mut Vec<CellOccupancy>,
2774        sample: &mut StationLoadSample,
2775    ) {
2776        let (owned_entities, ghost_entities) = count_station_roles(station);
2777        sample.cells.clear();
2778        if let Some(index) = index {
2779            index.cell_occupancy_into(occupancy);
2780            for occupancy in occupancy.iter() {
2781                let mut cell_owned_entities = 0usize;
2782                let mut cell_ghost_entities = 0usize;
2783                for handle in index.handles_in_cell_slice(occupancy.cell) {
2784                    if let Some(record) = station.get(*handle) {
2785                        if record.is_owned() {
2786                            cell_owned_entities = cell_owned_entities.saturating_add(1);
2787                        } else {
2788                            cell_ghost_entities = cell_ghost_entities.saturating_add(1);
2789                        }
2790                    }
2791                }
2792                let entities = cell_owned_entities.saturating_add(cell_ghost_entities);
2793                sample.cells.push(CellLoadSample {
2794                    cell: occupancy.cell,
2795                    owned_entities: cell_owned_entities,
2796                    ghost_entities: cell_ghost_entities,
2797                    subscribers: 0,
2798                    estimated_updates: entities,
2799                    estimated_bytes: entities
2800                        .saturating_mul(self.config.estimated_bytes_per_entity),
2801                    tick_cost_units: self.estimate_tick_cost(
2802                        cell_owned_entities,
2803                        cell_ghost_entities,
2804                        1,
2805                        0,
2806                    ),
2807                    event_pressure: 0,
2808                });
2809            }
2810        } else {
2811            occupancy.clear();
2812        }
2813        sample.station_id = station.config().station_id;
2814        sample.owned_entities = owned_entities;
2815        sample.ghost_entities = ghost_entities;
2816        sample.subscribers = subscribers;
2817        sample.queued_events = queued_events;
2818        sample.estimated_bytes =
2819            self.estimate_station_bytes(owned_entities, ghost_entities, subscribers, queued_events);
2820        sample.tick_cost_units = self.estimate_tick_cost(
2821            owned_entities,
2822            ghost_entities,
2823            sample.cells.len(),
2824            queued_events,
2825        );
2826    }
2827
2828    fn estimate_station_bytes(
2829        &self,
2830        owned_entities: usize,
2831        ghost_entities: usize,
2832        subscribers: usize,
2833        queued_events: usize,
2834    ) -> usize {
2835        owned_entities
2836            .saturating_add(ghost_entities)
2837            .saturating_mul(self.config.estimated_bytes_per_entity)
2838            .saturating_add(subscribers.saturating_mul(self.config.estimated_bytes_per_subscriber))
2839            .saturating_add(queued_events.saturating_mul(self.config.estimated_bytes_per_event))
2840    }
2841
2842    fn estimate_tick_cost(
2843        &self,
2844        owned_entities: usize,
2845        ghost_entities: usize,
2846        occupied_cells: usize,
2847        queued_events: usize,
2848    ) -> u64 {
2849        (owned_entities as u64)
2850            .saturating_mul(self.config.tick_cost_per_owned_entity)
2851            .saturating_add(
2852                (ghost_entities as u64).saturating_mul(self.config.tick_cost_per_ghost_entity),
2853            )
2854            .saturating_add(
2855                (occupied_cells as u64).saturating_mul(self.config.tick_cost_per_occupied_cell),
2856            )
2857            .saturating_add(
2858                (queued_events as u64).saturating_mul(self.config.tick_cost_per_queued_event),
2859            )
2860    }
2861}
2862
2863impl Default for StationLoadSampler {
2864    fn default() -> Self {
2865        Self::new(StationLoadSamplerConfig::default())
2866    }
2867}
2868
2869fn count_station_roles(station: &Station) -> (usize, usize) {
2870    let mut owned_entities = 0usize;
2871    let mut ghost_entities = 0usize;
2872    for record in station.iter() {
2873        if record.is_owned() {
2874            owned_entities = owned_entities.saturating_add(1);
2875        } else {
2876            ghost_entities = ghost_entities.saturating_add(1);
2877        }
2878    }
2879    (owned_entities, ghost_entities)
2880}
2881
2882/// Result of an in-process entity owner migration.
2883#[derive(Clone, Debug, PartialEq)]
2884pub struct EntityMigrationReport {
2885    /// Transfer payload used for the migration.
2886    pub transfer: HandoffTransfer,
2887    /// Source-side ghost handle after commit.
2888    pub source_ghost: EntityHandle,
2889    /// Target-side authoritative handle after commit.
2890    pub target_owner: EntityHandle,
2891}
2892
2893/// Entity migration error.
2894#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2895pub enum EntityMigrationError {
2896    /// Source and target station ids must differ.
2897    SameSourceAndTarget(StationId),
2898    /// Source station was not found.
2899    MissingSource(StationId),
2900    /// Target station was not found.
2901    MissingTarget(StationId),
2902    /// Station-level operation failed.
2903    Station(StationError),
2904}
2905
2906impl core::fmt::Display for EntityMigrationError {
2907    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2908        match self {
2909            Self::SameSourceAndTarget(id) => {
2910                write!(f, "source and target station are both {}", id.get())
2911            }
2912            Self::MissingSource(id) => write!(f, "source station {} is missing", id.get()),
2913            Self::MissingTarget(id) => write!(f, "target station {} is missing", id.get()),
2914            Self::Station(error) => write!(f, "{error}"),
2915        }
2916    }
2917}
2918
2919impl std::error::Error for EntityMigrationError {}
2920
2921impl From<StationError> for EntityMigrationError {
2922    fn from(value: StationError) -> Self {
2923        Self::Station(value)
2924    }
2925}
2926
2927/// Runtime helper for in-process station-to-station owner migration.
2928#[derive(Clone, Copy, Debug, Default)]
2929pub struct EntityMigrationExecutor;
2930
2931impl EntityMigrationExecutor {
2932    /// Migrates one authoritative entity from source station to target station.
2933    pub fn migrate_entity(
2934        stations: &mut StationSet,
2935        entity_id: EntityId,
2936        source_station: StationId,
2937        target_station: StationId,
2938        ghost_ttl_ticks: u64,
2939    ) -> Result<EntityMigrationReport, EntityMigrationError> {
2940        if source_station == target_station {
2941            return Err(EntityMigrationError::SameSourceAndTarget(source_station));
2942        }
2943
2944        if stations.get(source_station).is_none() {
2945            return Err(EntityMigrationError::MissingSource(source_station));
2946        }
2947        if stations.get(target_station).is_none() {
2948            return Err(EntityMigrationError::MissingTarget(target_station));
2949        }
2950
2951        let (source, target) = stations
2952            .get_pair_mut(source_station, target_station)
2953            .expect("stations were checked above");
2954        let target_epoch = next_target_epoch(target);
2955        let source_ghost_expires_at =
2956            Tick::new(source.tick().get().saturating_add(ghost_ttl_ticks));
2957        let transfer = source.prepare_outgoing_handoff(
2958            entity_id,
2959            target_station,
2960            target_epoch,
2961            source_ghost_expires_at,
2962        )?;
2963        target.prewarm_handoff_ghost(&transfer)?;
2964        let target_owner = target.commit_incoming_handoff(transfer.clone())?;
2965        let source_ghost = source.commit_outgoing_handoff(&transfer)?;
2966
2967        Ok(EntityMigrationReport {
2968            transfer,
2969            source_ghost,
2970            target_owner,
2971        })
2972    }
2973}
2974
2975fn next_target_epoch(station: &mut Station) -> OwnerEpoch {
2976    station.next_owner_epoch()
2977}
2978
2979/// Dynamic ownership table for fixed 3D cells.
2980#[derive(Clone, Debug, Default, PartialEq, Eq)]
2981pub struct CellOwnershipTable {
2982    owners: BTreeMap<CellCoord3, StationId>,
2983}
2984
2985impl CellOwnershipTable {
2986    /// Assigns one cell to a station and returns the previous owner.
2987    pub fn assign(&mut self, cell: CellCoord3, station_id: StationId) -> Option<StationId> {
2988        self.owners.insert(cell, station_id)
2989    }
2990
2991    /// Returns the current owner for one cell.
2992    pub fn owner_of(&self, cell: CellCoord3) -> Option<StationId> {
2993        self.owners.get(&cell).copied()
2994    }
2995
2996    /// Applies a split proposal by assigning all proposed cells to `target_station`.
2997    pub fn apply_split(
2998        &mut self,
2999        proposal: &SplitProposal,
3000        target_station: StationId,
3001    ) -> CellOwnershipUpdate {
3002        let mut update = CellOwnershipUpdate::default();
3003        self.apply_split_into(proposal, target_station, &mut update);
3004        update
3005    }
3006
3007    /// Applies a split into caller-owned reusable update storage.
3008    pub fn apply_split_into(
3009        &mut self,
3010        proposal: &SplitProposal,
3011        target_station: StationId,
3012        update: &mut CellOwnershipUpdate,
3013    ) {
3014        update.source_station = proposal.source_station;
3015        update.target_station = target_station;
3016        update.moved_cells.clear();
3017        for cell in &proposal.cells_to_move {
3018            let previous = self.assign(*cell, target_station);
3019            if previous != Some(target_station) {
3020                update.moved_cells.push(*cell);
3021            }
3022        }
3023    }
3024
3025    /// Number of explicitly assigned cells.
3026    pub fn len(&self) -> usize {
3027        self.owners.len()
3028    }
3029
3030    /// Returns whether no cells are explicitly assigned.
3031    pub fn is_empty(&self) -> bool {
3032        self.owners.is_empty()
3033    }
3034}
3035
3036/// Result of applying cell ownership changes.
3037#[derive(Clone, Debug, Default, PartialEq, Eq)]
3038pub struct CellOwnershipUpdate {
3039    /// Previous/source station.
3040    pub source_station: StationId,
3041    /// New/target station.
3042    pub target_station: StationId,
3043    /// Cells whose owner changed.
3044    pub moved_cells: Vec<CellCoord3>,
3045}
3046
3047/// Result of migrating entities indexed by moved cells.
3048#[derive(Clone, Debug, Default, PartialEq)]
3049pub struct CellMigrationReport {
3050    /// Source station.
3051    pub source_station: StationId,
3052    /// Target station.
3053    pub target_station: StationId,
3054    /// Cells scanned for owner entities.
3055    pub scanned_cells: Vec<CellCoord3>,
3056    /// Entity migrations that were committed.
3057    pub entity_migrations: Vec<EntityMigrationReport>,
3058    /// Candidate handles that no longer resolved to an entity.
3059    pub skipped_missing_handles: usize,
3060    /// Candidate entities skipped because they were ghosts or non-authoritative.
3061    pub skipped_non_owned: usize,
3062    /// Duplicate candidate entities skipped after first occurrence.
3063    pub skipped_duplicate_entities: usize,
3064}
3065
3066/// Caller-owned working storage for repeated cell migration passes.
3067#[derive(Clone, Debug, Default)]
3068pub struct CellMigrationScratch {
3069    seen_handles: HashSet<EntityHandle>,
3070    seen_entities: HashSet<EntityId>,
3071    entity_ids: Vec<EntityId>,
3072}
3073
3074impl CellMigrationScratch {
3075    /// Creates empty migration scratch storage.
3076    pub fn new() -> Self {
3077        Self::default()
3078    }
3079
3080    /// Reserves deduplication and candidate storage for an expected pass size.
3081    pub fn reserve(&mut self, handles: usize, entities: usize) {
3082        if self.seen_handles.capacity() < handles {
3083            self.seen_handles
3084                .reserve(handles.saturating_sub(self.seen_handles.len()));
3085        }
3086        if self.seen_entities.capacity() < entities {
3087            self.seen_entities
3088                .reserve(entities.saturating_sub(self.seen_entities.len()));
3089        }
3090        if self.entity_ids.capacity() < entities {
3091            self.entity_ids
3092                .reserve(entities.saturating_sub(self.entity_ids.len()));
3093        }
3094    }
3095
3096    /// Retained handle-deduplication capacity.
3097    pub fn handle_capacity(&self) -> usize {
3098        self.seen_handles.capacity()
3099    }
3100
3101    /// Retained entity-deduplication capacity.
3102    pub fn entity_capacity(&self) -> usize {
3103        self.seen_entities.capacity()
3104    }
3105
3106    /// Retained candidate entity capacity.
3107    pub fn candidate_capacity(&self) -> usize {
3108        self.entity_ids.capacity()
3109    }
3110
3111    fn clear(&mut self) {
3112        self.seen_handles.clear();
3113        self.seen_entities.clear();
3114        self.entity_ids.clear();
3115    }
3116}
3117
3118/// Cell-level migration error.
3119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3120pub enum CellMigrationError {
3121    /// Entity migration failed.
3122    Entity(EntityMigrationError),
3123    /// Target owner record was not found after a successful migration.
3124    MissingTargetRecord(EntityId),
3125    /// Source ghost record was not found after a successful migration.
3126    MissingSourceRecord(EntityId),
3127}
3128
3129impl core::fmt::Display for CellMigrationError {
3130    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3131        match self {
3132            Self::Entity(error) => write!(f, "{error}"),
3133            Self::MissingTargetRecord(id) => {
3134                write!(f, "target owner record for entity {} is missing", id.get())
3135            }
3136            Self::MissingSourceRecord(id) => {
3137                write!(f, "source ghost record for entity {} is missing", id.get())
3138            }
3139        }
3140    }
3141}
3142
3143impl std::error::Error for CellMigrationError {}
3144
3145impl From<EntityMigrationError> for CellMigrationError {
3146    fn from(value: EntityMigrationError) -> Self {
3147        Self::Entity(value)
3148    }
3149}
3150
3151/// Executes cell-level ownership migration using station-local indexes.
3152#[derive(Clone, Copy, Debug, Default)]
3153pub struct CellMigrationExecutor;
3154
3155impl CellMigrationExecutor {
3156    /// Migrates owned entities found in `cells` from source station to target station.
3157    pub fn migrate_cells(
3158        stations: &mut StationSet,
3159        source_index: &mut CellIndex,
3160        target_index: &mut CellIndex,
3161        source_station: StationId,
3162        target_station: StationId,
3163        cells: &[CellCoord3],
3164        ghost_ttl_ticks: u64,
3165    ) -> Result<CellMigrationReport, CellMigrationError> {
3166        let mut report = CellMigrationReport::default();
3167        let mut scratch = CellMigrationScratch::new();
3168        Self::migrate_cells_into(
3169            stations,
3170            source_index,
3171            target_index,
3172            source_station,
3173            target_station,
3174            cells,
3175            ghost_ttl_ticks,
3176            &mut scratch,
3177            &mut report,
3178        )?;
3179        Ok(report)
3180    }
3181
3182    /// Migrates cells using caller-owned reusable working and report storage.
3183    ///
3184    /// The report is reset before processing. As with [`Self::migrate_cells`],
3185    /// an error may occur after earlier entities have already migrated.
3186    #[allow(clippy::too_many_arguments)]
3187    pub fn migrate_cells_into(
3188        stations: &mut StationSet,
3189        source_index: &mut CellIndex,
3190        target_index: &mut CellIndex,
3191        source_station: StationId,
3192        target_station: StationId,
3193        cells: &[CellCoord3],
3194        ghost_ttl_ticks: u64,
3195        scratch: &mut CellMigrationScratch,
3196        report: &mut CellMigrationReport,
3197    ) -> Result<(), CellMigrationError> {
3198        report.source_station = source_station;
3199        report.target_station = target_station;
3200        report.scanned_cells.clear();
3201        report.scanned_cells.extend_from_slice(cells);
3202        report.entity_migrations.clear();
3203        report.skipped_missing_handles = 0;
3204        report.skipped_non_owned = 0;
3205        report.skipped_duplicate_entities = 0;
3206        scratch.clear();
3207
3208        {
3209            let source = stations
3210                .get(source_station)
3211                .ok_or(EntityMigrationError::MissingSource(source_station))?;
3212            for cell in cells {
3213                for &handle in source_index.handles_in_cell_slice(*cell) {
3214                    if !scratch.seen_handles.insert(handle) {
3215                        report.skipped_duplicate_entities += 1;
3216                        continue;
3217                    }
3218                    let Some(record) = source.get(handle) else {
3219                        report.skipped_missing_handles += 1;
3220                        continue;
3221                    };
3222                    if record.is_owned() {
3223                        scratch.entity_ids.push(record.id);
3224                    } else {
3225                        report.skipped_non_owned += 1;
3226                    }
3227                }
3228            }
3229        }
3230
3231        for &entity_id in &scratch.entity_ids {
3232            if !scratch.seen_entities.insert(entity_id) {
3233                report.skipped_duplicate_entities += 1;
3234                continue;
3235            }
3236            let migration = EntityMigrationExecutor::migrate_entity(
3237                stations,
3238                entity_id,
3239                source_station,
3240                target_station,
3241                ghost_ttl_ticks,
3242            )?;
3243
3244            {
3245                let target = stations
3246                    .get(target_station)
3247                    .ok_or(EntityMigrationError::MissingTarget(target_station))?;
3248                let target_record = target
3249                    .get(migration.target_owner)
3250                    .ok_or(CellMigrationError::MissingTargetRecord(entity_id))?;
3251                target_index.upsert(
3252                    migration.target_owner,
3253                    target_record.position,
3254                    target_record.bounds,
3255                );
3256            }
3257
3258            {
3259                let source = stations
3260                    .get(source_station)
3261                    .ok_or(EntityMigrationError::MissingSource(source_station))?;
3262                let source_record = source
3263                    .get(migration.source_ghost)
3264                    .ok_or(CellMigrationError::MissingSourceRecord(entity_id))?;
3265                source_index.upsert(
3266                    migration.source_ghost,
3267                    source_record.position,
3268                    source_record.bounds,
3269                );
3270            }
3271
3272            report.entity_migrations.push(migration);
3273        }
3274
3275        Ok(())
3276    }
3277}
3278
3279/// Automatic split scheduler configuration.
3280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3281pub struct SplitSchedulerConfig {
3282    /// Hotspot thresholds.
3283    pub thresholds: HotspotThresholds,
3284    /// Maximum split actions to create per scheduling pass.
3285    pub max_actions_per_pass: usize,
3286    /// Maximum cells to move in each split action.
3287    pub max_cells_per_action: usize,
3288    /// Source ghost TTL used during migration execution.
3289    pub ghost_ttl_ticks: u64,
3290    /// Minimum load-score gap required between source and target.
3291    pub min_score_improvement: u64,
3292    /// Maximum permitted target load score after moved cell pressure is added.
3293    pub max_target_score_after_move: u64,
3294    /// Ticks a source station must wait before another split can be planned.
3295    pub split_cooldown_ticks: u64,
3296    /// Whether warm target stations may receive split cells.
3297    pub allow_warm_targets: bool,
3298}
3299
3300impl Default for SplitSchedulerConfig {
3301    fn default() -> Self {
3302        Self {
3303            thresholds: HotspotThresholds::default(),
3304            max_actions_per_pass: 4,
3305            max_cells_per_action: 4,
3306            ghost_ttl_ticks: 4,
3307            min_score_improvement: 1,
3308            max_target_score_after_move: u64::MAX,
3309            split_cooldown_ticks: 0,
3310            allow_warm_targets: true,
3311        }
3312    }
3313}
3314
3315/// One scheduled split action.
3316#[derive(Clone, Debug, Default, PartialEq, Eq)]
3317pub struct SplitAction {
3318    /// Source station selected for split.
3319    pub source_station: StationId,
3320    /// Target station selected to receive cells.
3321    pub target_station: StationId,
3322    /// Cell split proposal.
3323    pub proposal: SplitProposal,
3324    /// Source load score observed when planning.
3325    pub source_score: u64,
3326    /// Target load score observed when planning.
3327    pub target_score: u64,
3328    /// Estimated target score after moving proposed cell pressure.
3329    pub estimated_target_score_after_move: u64,
3330}
3331
3332/// Split schedule produced from a load snapshot.
3333#[derive(Clone, Debug, Default, PartialEq, Eq)]
3334pub struct SplitSchedule {
3335    /// Hotspot decisions produced for every input station.
3336    pub decisions: Vec<HotspotDecision>,
3337    /// Actions selected for execution.
3338    pub actions: Vec<SplitAction>,
3339    /// Hot stations skipped because no distinct target existed.
3340    pub skipped_no_target: usize,
3341    /// Hot stations skipped because no cells were proposed.
3342    pub skipped_no_cells: usize,
3343    /// Hot stations skipped because source station is inside split cooldown.
3344    pub skipped_cooldown: usize,
3345    /// Hot stations skipped because all targets were too warm or hot.
3346    pub skipped_target_severity: usize,
3347    /// Hot stations skipped because target capacity would be exceeded.
3348    pub skipped_target_capacity: usize,
3349    /// Hot stations skipped because target score improvement was too small.
3350    pub skipped_insufficient_improvement: usize,
3351}
3352
3353/// Borrowed split schedule produced from reusable scheduler output slots.
3354#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3355pub struct SplitScheduleView<'a> {
3356    /// Hotspot decisions in input sample order.
3357    pub decisions: &'a [HotspotDecision],
3358    /// Admitted split actions in deterministic source order.
3359    pub actions: &'a [SplitAction],
3360    /// Hot stations skipped because no distinct target existed.
3361    pub skipped_no_target: usize,
3362    /// Hot stations skipped because no cells were proposed.
3363    pub skipped_no_cells: usize,
3364    /// Hot stations skipped because source station is inside split cooldown.
3365    pub skipped_cooldown: usize,
3366    /// Hot stations skipped because all targets were too warm or hot.
3367    pub skipped_target_severity: usize,
3368    /// Hot stations skipped because target capacity would be exceeded.
3369    pub skipped_target_capacity: usize,
3370    /// Hot stations skipped because target score improvement was too small.
3371    pub skipped_insufficient_improvement: usize,
3372}
3373
3374impl SplitSchedule {
3375    /// Borrows this owned schedule for the canonical execution kernel.
3376    pub fn view(&self) -> SplitScheduleView<'_> {
3377        SplitScheduleView {
3378            decisions: &self.decisions,
3379            actions: &self.actions,
3380            skipped_no_target: self.skipped_no_target,
3381            skipped_no_cells: self.skipped_no_cells,
3382            skipped_cooldown: self.skipped_cooldown,
3383            skipped_target_severity: self.skipped_target_severity,
3384            skipped_target_capacity: self.skipped_target_capacity,
3385            skipped_insufficient_improvement: self.skipped_insufficient_improvement,
3386        }
3387    }
3388}
3389
3390impl From<SplitScheduleView<'_>> for SplitSchedule {
3391    fn from(view: SplitScheduleView<'_>) -> Self {
3392        Self {
3393            decisions: view.decisions.to_vec(),
3394            actions: view.actions.to_vec(),
3395            skipped_no_target: view.skipped_no_target,
3396            skipped_no_cells: view.skipped_no_cells,
3397            skipped_cooldown: view.skipped_cooldown,
3398            skipped_target_severity: view.skipped_target_severity,
3399            skipped_target_capacity: view.skipped_target_capacity,
3400            skipped_insufficient_improvement: view.skipped_insufficient_improvement,
3401        }
3402    }
3403}
3404
3405/// Caller-owned reusable output and working storage for split scheduling.
3406#[derive(Clone, Debug, Default)]
3407pub struct SplitSchedulerScratch {
3408    decisions: Vec<HotspotDecision>,
3409    active_decisions: usize,
3410    actions: Vec<SplitAction>,
3411    active_actions: usize,
3412    hotspot: HotspotSplitScratch,
3413    proposal: SplitProposal,
3414    skipped_no_target: usize,
3415    skipped_no_cells: usize,
3416    skipped_cooldown: usize,
3417    skipped_target_severity: usize,
3418    skipped_target_capacity: usize,
3419    skipped_insufficient_improvement: usize,
3420}
3421
3422impl SplitSchedulerScratch {
3423    /// Creates empty scheduler storage.
3424    pub fn new() -> Self {
3425        Self::default()
3426    }
3427
3428    /// Decision slots retained across passes.
3429    pub fn retained_decision_slots(&self) -> usize {
3430        self.decisions.len()
3431    }
3432
3433    /// Action slots retained across passes.
3434    pub fn retained_action_slots(&self) -> usize {
3435        self.actions.len()
3436    }
3437
3438    /// Total reason capacity retained across decision slots.
3439    pub fn retained_reason_capacity(&self) -> usize {
3440        self.decisions
3441            .iter()
3442            .map(|decision| decision.reasons.capacity())
3443            .sum()
3444    }
3445
3446    /// Total cell-coordinate capacity retained across action proposal slots.
3447    pub fn retained_action_cell_capacity(&self) -> usize {
3448        self.actions
3449            .iter()
3450            .map(|action| action.proposal.cells_to_move.capacity())
3451            .sum()
3452    }
3453
3454    /// Hotspot candidate capacity retained across passes.
3455    pub fn retained_candidate_capacity(&self) -> usize {
3456        self.hotspot.candidate_capacity()
3457    }
3458
3459    fn prepare(&mut self, decisions: usize) {
3460        if self.decisions.len() < decisions {
3461            self.decisions
3462                .resize_with(decisions, HotspotDecision::default);
3463        }
3464        self.active_decisions = decisions;
3465        self.active_actions = 0;
3466        self.skipped_no_target = 0;
3467        self.skipped_no_cells = 0;
3468        self.skipped_cooldown = 0;
3469        self.skipped_target_severity = 0;
3470        self.skipped_target_capacity = 0;
3471        self.skipped_insufficient_improvement = 0;
3472    }
3473
3474    /// Returns the active schedule from the most recent planning call.
3475    pub fn view(&self) -> SplitScheduleView<'_> {
3476        SplitScheduleView {
3477            decisions: &self.decisions[..self.active_decisions],
3478            actions: &self.actions[..self.active_actions],
3479            skipped_no_target: self.skipped_no_target,
3480            skipped_no_cells: self.skipped_no_cells,
3481            skipped_cooldown: self.skipped_cooldown,
3482            skipped_target_severity: self.skipped_target_severity,
3483            skipped_target_capacity: self.skipped_target_capacity,
3484            skipped_insufficient_improvement: self.skipped_insufficient_improvement,
3485        }
3486    }
3487}
3488
3489/// Mutable planning state for conservative split scheduling.
3490#[derive(Clone, Debug, Default, PartialEq, Eq)]
3491pub struct SplitSchedulerState {
3492    last_split_at: BTreeMap<StationId, Tick>,
3493}
3494
3495impl SplitSchedulerState {
3496    /// Returns the last split tick for a source station.
3497    pub fn last_split_at(&self, station_id: StationId) -> Option<Tick> {
3498        self.last_split_at.get(&station_id).copied()
3499    }
3500
3501    /// Records one executed or externally accepted split action.
3502    pub fn record_action(&mut self, action: &SplitAction, tick: Tick) {
3503        self.last_split_at.insert(action.source_station, tick);
3504    }
3505
3506    /// Records all actions in a schedule at the same tick.
3507    pub fn record_schedule(&mut self, schedule: &SplitSchedule, tick: Tick) {
3508        for action in &schedule.actions {
3509            self.record_action(action, tick);
3510        }
3511    }
3512
3513    /// Records all actions from a borrowed reusable schedule view.
3514    pub fn record_schedule_view(&mut self, schedule: SplitScheduleView<'_>, tick: Tick) {
3515        for action in schedule.actions {
3516            self.record_action(action, tick);
3517        }
3518    }
3519
3520    /// Returns whether a station is inside split cooldown.
3521    pub fn is_in_cooldown(
3522        &self,
3523        station_id: StationId,
3524        current_tick: Tick,
3525        cooldown_ticks: u64,
3526    ) -> bool {
3527        if cooldown_ticks == 0 {
3528            return false;
3529        }
3530        let Some(last_split) = self.last_split_at(station_id) else {
3531            return false;
3532        };
3533        current_tick.get().saturating_sub(last_split.get()) < cooldown_ticks
3534    }
3535}
3536
3537/// Result of executing a split schedule.
3538#[derive(Clone, Debug, Default, PartialEq)]
3539pub struct SplitScheduleExecutionReport {
3540    /// Ownership changes applied.
3541    pub ownership_updates: Vec<CellOwnershipUpdate>,
3542    /// Cell migration reports.
3543    pub cell_migrations: Vec<CellMigrationReport>,
3544}
3545
3546/// Borrowed result of executing a split schedule into reusable storage.
3547#[derive(Clone, Copy, Debug, PartialEq)]
3548pub struct SplitScheduleExecutionView<'a> {
3549    /// Ownership changes applied during this pass.
3550    pub ownership_updates: &'a [CellOwnershipUpdate],
3551    /// Cell migration reports produced during this pass.
3552    pub cell_migrations: &'a [CellMigrationReport],
3553}
3554
3555/// Caller-owned reusable output and working storage for split execution.
3556#[derive(Clone, Debug, Default)]
3557pub struct SplitScheduleExecutionScratch {
3558    ownership_updates: Vec<CellOwnershipUpdate>,
3559    cell_migrations: Vec<CellMigrationReport>,
3560    active_actions: usize,
3561    migration: CellMigrationScratch,
3562}
3563
3564impl SplitScheduleExecutionScratch {
3565    /// Creates empty split-execution storage.
3566    pub fn new() -> Self {
3567        Self::default()
3568    }
3569
3570    /// Reserves outer action slots and per-action cell/entity report capacity.
3571    pub fn reserve(&mut self, actions: usize, cells_per_action: usize, entities_per_action: usize) {
3572        while self.ownership_updates.len() < actions {
3573            self.ownership_updates.push(CellOwnershipUpdate::default());
3574            self.cell_migrations.push(CellMigrationReport::default());
3575        }
3576        for update in &mut self.ownership_updates[..actions] {
3577            if update.moved_cells.capacity() < cells_per_action {
3578                update
3579                    .moved_cells
3580                    .reserve(cells_per_action.saturating_sub(update.moved_cells.len()));
3581            }
3582        }
3583        for report in &mut self.cell_migrations[..actions] {
3584            if report.scanned_cells.capacity() < cells_per_action {
3585                report
3586                    .scanned_cells
3587                    .reserve(cells_per_action.saturating_sub(report.scanned_cells.len()));
3588            }
3589            if report.entity_migrations.capacity() < entities_per_action {
3590                report
3591                    .entity_migrations
3592                    .reserve(entities_per_action.saturating_sub(report.entity_migrations.len()));
3593            }
3594        }
3595        self.migration
3596            .reserve(entities_per_action, entities_per_action);
3597    }
3598
3599    /// Ownership-update slots retained across passes.
3600    pub fn retained_ownership_slots(&self) -> usize {
3601        self.ownership_updates.len()
3602    }
3603
3604    /// Cell-migration report slots retained across passes.
3605    pub fn retained_migration_slots(&self) -> usize {
3606        self.cell_migrations.len()
3607    }
3608
3609    /// Total moved-cell capacity retained across ownership updates.
3610    pub fn retained_update_cell_capacity(&self) -> usize {
3611        self.ownership_updates
3612            .iter()
3613            .map(|update| update.moved_cells.capacity())
3614            .sum()
3615    }
3616
3617    /// Total entity-report capacity retained across cell migrations.
3618    pub fn retained_entity_migration_capacity(&self) -> usize {
3619        self.cell_migrations
3620            .iter()
3621            .map(|report| report.entity_migrations.capacity())
3622            .sum()
3623    }
3624
3625    /// Retained candidate entity capacity in the shared migration scratch.
3626    pub fn retained_candidate_capacity(&self) -> usize {
3627        self.migration.candidate_capacity()
3628    }
3629
3630    fn prepare(&mut self, actions: usize) {
3631        while self.ownership_updates.len() < actions {
3632            self.ownership_updates.push(CellOwnershipUpdate::default());
3633            self.cell_migrations.push(CellMigrationReport::default());
3634        }
3635        self.active_actions = 0;
3636    }
3637
3638    fn view(&self) -> SplitScheduleExecutionView<'_> {
3639        SplitScheduleExecutionView {
3640            ownership_updates: &self.ownership_updates[..self.active_actions],
3641            cell_migrations: &self.cell_migrations[..self.active_actions],
3642        }
3643    }
3644}
3645
3646/// Split schedule execution error.
3647#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3648pub enum SplitScheduleExecutionError {
3649    /// Source index is missing.
3650    MissingSourceIndex(StationId),
3651    /// Target index is missing.
3652    MissingTargetIndex(StationId),
3653    /// Cell migration failed.
3654    CellMigration(CellMigrationError),
3655}
3656
3657impl core::fmt::Display for SplitScheduleExecutionError {
3658    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3659        match self {
3660            Self::MissingSourceIndex(id) => write!(f, "source index {} is missing", id.get()),
3661            Self::MissingTargetIndex(id) => write!(f, "target index {} is missing", id.get()),
3662            Self::CellMigration(error) => write!(f, "{error}"),
3663        }
3664    }
3665}
3666
3667impl std::error::Error for SplitScheduleExecutionError {}
3668
3669impl From<CellMigrationError> for SplitScheduleExecutionError {
3670    fn from(value: CellMigrationError) -> Self {
3671        Self::CellMigration(value)
3672    }
3673}
3674
3675/// Conservative automatic split scheduler.
3676#[derive(Clone, Copy, Debug)]
3677pub struct SplitScheduler {
3678    /// Scheduler configuration.
3679    pub config: SplitSchedulerConfig,
3680}
3681
3682impl SplitScheduler {
3683    /// Creates a split scheduler.
3684    pub const fn new(config: SplitSchedulerConfig) -> Self {
3685        Self { config }
3686    }
3687
3688    /// Plans with optional cooldown state into fully reusable caller-owned storage.
3689    pub fn plan_into<'a>(
3690        &self,
3691        samples: &[StationLoadSample],
3692        state: Option<&SplitSchedulerState>,
3693        current_tick: Tick,
3694        scratch: &'a mut SplitSchedulerScratch,
3695    ) -> SplitScheduleView<'a> {
3696        scratch.prepare(samples.len());
3697        for (decision, sample) in scratch.decisions[..samples.len()].iter_mut().zip(samples) {
3698            HotspotPlanner::evaluate_into(sample, self.config.thresholds, decision);
3699        }
3700
3701        for (source_index, source) in samples.iter().enumerate() {
3702            if scratch.active_actions >= self.config.max_actions_per_pass {
3703                break;
3704            }
3705            let source_decision = &scratch.decisions[source_index];
3706            if source_decision.severity != HotspotSeverity::Hot {
3707                continue;
3708            }
3709            if state.is_some_and(|state| {
3710                state.is_in_cooldown(
3711                    source.station_id,
3712                    current_tick,
3713                    self.config.split_cooldown_ticks,
3714                )
3715            }) {
3716                scratch.skipped_cooldown = scratch.skipped_cooldown.saturating_add(1);
3717                continue;
3718            }
3719
3720            HotspotPlanner::propose_cell_split_into(
3721                source,
3722                self.config.max_cells_per_action,
3723                &mut scratch.hotspot,
3724                &mut scratch.proposal,
3725            );
3726            if scratch.proposal.cells_to_move.is_empty() {
3727                scratch.skipped_no_cells = scratch.skipped_no_cells.saturating_add(1);
3728                continue;
3729            }
3730            let target_selection = select_split_target(
3731                source,
3732                &scratch.proposal,
3733                samples,
3734                &scratch.decisions[..scratch.active_decisions],
3735                self.config,
3736            );
3737            let Some(target) = target_selection.target else {
3738                if target_selection.considered_targets == 0 {
3739                    scratch.skipped_no_target = scratch.skipped_no_target.saturating_add(1);
3740                } else {
3741                    scratch.skipped_target_severity = scratch
3742                        .skipped_target_severity
3743                        .saturating_add(usize::from(target_selection.rejected_by_severity > 0));
3744                    scratch.skipped_target_capacity = scratch
3745                        .skipped_target_capacity
3746                        .saturating_add(usize::from(target_selection.rejected_by_capacity > 0));
3747                    scratch.skipped_insufficient_improvement = scratch
3748                        .skipped_insufficient_improvement
3749                        .saturating_add(usize::from(target_selection.rejected_by_improvement > 0));
3750                }
3751                continue;
3752            };
3753            let target_score = station_load_score(target);
3754            let estimated_target_score_after_move =
3755                target_score.saturating_add(scratch.proposal.moved_pressure_score);
3756            let action_index = scratch.active_actions;
3757            if action_index == scratch.actions.len() {
3758                scratch.actions.push(SplitAction::default());
3759            }
3760            let action = &mut scratch.actions[action_index];
3761            action.source_station = source.station_id;
3762            action.target_station = target.station_id;
3763            action.proposal.source_station = scratch.proposal.source_station;
3764            action.proposal.cells_to_move.clear();
3765            action
3766                .proposal
3767                .cells_to_move
3768                .extend_from_slice(&scratch.proposal.cells_to_move);
3769            action.proposal.moved_pressure_score = scratch.proposal.moved_pressure_score;
3770            action.source_score = station_load_score(source);
3771            action.target_score = target_score;
3772            action.estimated_target_score_after_move = estimated_target_score_after_move;
3773            scratch.active_actions = scratch.active_actions.saturating_add(1);
3774        }
3775
3776        scratch.view()
3777    }
3778
3779    /// Executes a borrowed schedule into fully reusable caller-owned storage.
3780    pub fn execute_into<'a>(
3781        &self,
3782        schedule: SplitScheduleView<'_>,
3783        stations: &mut StationSet,
3784        indexes: &mut StationIndexSet,
3785        ownership: &mut CellOwnershipTable,
3786        scratch: &'a mut SplitScheduleExecutionScratch,
3787    ) -> Result<SplitScheduleExecutionView<'a>, SplitScheduleExecutionError> {
3788        self.execute_actions_into(schedule.actions, stations, indexes, ownership, scratch)
3789    }
3790
3791    fn execute_actions_into<'a>(
3792        &self,
3793        actions: &[SplitAction],
3794        stations: &mut StationSet,
3795        indexes: &mut StationIndexSet,
3796        ownership: &mut CellOwnershipTable,
3797        scratch: &'a mut SplitScheduleExecutionScratch,
3798    ) -> Result<SplitScheduleExecutionView<'a>, SplitScheduleExecutionError> {
3799        scratch.prepare(actions.len());
3800
3801        for action in actions {
3802            if indexes.get(action.source_station).is_none() {
3803                return Err(SplitScheduleExecutionError::MissingSourceIndex(
3804                    action.source_station,
3805                ));
3806            }
3807            if indexes.get(action.target_station).is_none() {
3808                return Err(SplitScheduleExecutionError::MissingTargetIndex(
3809                    action.target_station,
3810                ));
3811            }
3812
3813            let action_index = scratch.active_actions;
3814            let update = &mut scratch.ownership_updates[action_index];
3815            ownership.apply_split_into(&action.proposal, action.target_station, update);
3816            let (source_index, target_index) = indexes
3817                .get_pair_mut(action.source_station, action.target_station)
3818                .expect("indexes were checked above");
3819            CellMigrationExecutor::migrate_cells_into(
3820                stations,
3821                source_index,
3822                target_index,
3823                action.source_station,
3824                action.target_station,
3825                &update.moved_cells,
3826                self.config.ghost_ttl_ticks,
3827                &mut scratch.migration,
3828                &mut scratch.cell_migrations[action_index],
3829            )?;
3830            scratch.active_actions = scratch.active_actions.saturating_add(1);
3831        }
3832
3833        Ok(scratch.view())
3834    }
3835}
3836
3837impl Default for SplitScheduler {
3838    fn default() -> Self {
3839        Self::new(SplitSchedulerConfig::default())
3840    }
3841}
3842
3843#[derive(Clone, Copy, Debug, Default)]
3844struct SplitTargetSelection<'a> {
3845    target: Option<&'a StationLoadSample>,
3846    target_key: Option<(u8, u64, u32)>,
3847    considered_targets: usize,
3848    rejected_by_severity: usize,
3849    rejected_by_capacity: usize,
3850    rejected_by_improvement: usize,
3851}
3852
3853fn select_split_target<'a>(
3854    source: &StationLoadSample,
3855    proposal: &SplitProposal,
3856    samples: &'a [StationLoadSample],
3857    decisions: &[HotspotDecision],
3858    config: SplitSchedulerConfig,
3859) -> SplitTargetSelection<'a> {
3860    let mut selection = SplitTargetSelection::default();
3861    let source_score = station_load_score(source);
3862
3863    for (target, decision) in samples.iter().zip(decisions) {
3864        if target.station_id == source.station_id {
3865            continue;
3866        }
3867        selection.considered_targets += 1;
3868
3869        debug_assert_eq!(decision.station_id, target.station_id);
3870        let severity = decision.severity;
3871        if severity == HotspotSeverity::Hot
3872            || (severity == HotspotSeverity::Warm && !config.allow_warm_targets)
3873        {
3874            selection.rejected_by_severity += 1;
3875            continue;
3876        }
3877
3878        let target_score = station_load_score(target);
3879        if source_score.saturating_sub(target_score) < config.min_score_improvement {
3880            selection.rejected_by_improvement += 1;
3881            continue;
3882        }
3883        if target_score.saturating_add(proposal.moved_pressure_score)
3884            > config.max_target_score_after_move
3885        {
3886            selection.rejected_by_capacity += 1;
3887            continue;
3888        }
3889
3890        let target_key = (
3891            severity_rank(severity),
3892            target_score,
3893            target.station_id.get(),
3894        );
3895        if selection
3896            .target_key
3897            .is_none_or(|current_key| target_key < current_key)
3898        {
3899            selection.target = Some(target);
3900            selection.target_key = Some(target_key);
3901        }
3902    }
3903
3904    selection
3905}
3906
3907fn severity_rank(severity: HotspotSeverity) -> u8 {
3908    match severity {
3909        HotspotSeverity::Normal => 0,
3910        HotspotSeverity::Warm => 1,
3911        HotspotSeverity::Hot => 2,
3912    }
3913}
3914
3915fn station_load_score(sample: &StationLoadSample) -> u64 {
3916    (sample.total_entities() as u64)
3917        .saturating_mul(8)
3918        .saturating_add((sample.subscribers as u64).saturating_mul(4))
3919        .saturating_add(sample.queued_events as u64)
3920        .saturating_add((sample.estimated_bytes / 256) as u64)
3921        .saturating_add(sample.tick_cost_units)
3922}
3923
3924/// Event router statistics.
3925#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
3926pub struct EventRouterStats {
3927    /// Events accepted by target queues.
3928    pub routed_events: usize,
3929    /// Ready events drained for station application.
3930    pub drained_events: usize,
3931    /// Best-effort events dropped by bounded target queues.
3932    pub dropped_best_effort_events: usize,
3933}
3934
3935/// Event router error.
3936#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3937pub enum EventRouterError {
3938    /// Target station was not registered with the router.
3939    MissingTarget(StationId),
3940    /// Underlying target queue rejected the event.
3941    Queue(EventQueueError),
3942}
3943
3944impl core::fmt::Display for EventRouterError {
3945    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
3946        match self {
3947            Self::MissingTarget(id) => write!(f, "event target station {} is missing", id.get()),
3948            Self::Queue(error) => write!(f, "{error}"),
3949        }
3950    }
3951}
3952
3953impl std::error::Error for EventRouterError {}
3954
3955impl From<EventQueueError> for EventRouterError {
3956    fn from(value: EventQueueError) -> Self {
3957        Self::Queue(value)
3958    }
3959}
3960
3961/// In-process station event router.
3962#[derive(Clone, Debug)]
3963pub struct EventRouter {
3964    limits: EventQueueLimits,
3965    queues: BTreeMap<StationId, EventQueues>,
3966    stats: EventRouterStats,
3967}
3968
3969impl EventRouter {
3970    /// Creates an empty event router.
3971    pub fn new(limits: EventQueueLimits) -> Self {
3972        Self {
3973            limits,
3974            queues: BTreeMap::new(),
3975            stats: EventRouterStats::default(),
3976        }
3977    }
3978
3979    /// Registers a station target queue.
3980    pub fn register_station(&mut self, station_id: StationId) {
3981        self.queues
3982            .entry(station_id)
3983            .or_insert_with(|| EventQueues::new(self.limits));
3984    }
3985
3986    /// Registers all stations in a set.
3987    pub fn register_stations(&mut self, stations: &StationSet) {
3988        for station in stations.iter() {
3989            self.register_station(station.config().station_id);
3990        }
3991    }
3992
3993    /// Unregisters a station queue and returns the number of queued events dropped.
3994    pub fn unregister_station(&mut self, station_id: StationId) -> Option<usize> {
3995        self.queues.remove(&station_id).map(|queue| queue.len())
3996    }
3997
3998    /// Routes an event to its target station queue.
3999    pub fn route(&mut self, event: StationEvent) -> Result<PushOutcome, EventRouterError> {
4000        let queue = self
4001            .queues
4002            .get_mut(&event.target)
4003            .ok_or(EventRouterError::MissingTarget(event.target))?;
4004        let outcome = queue.push(event)?;
4005        self.stats.routed_events += 1;
4006        if outcome == PushOutcome::DroppedOldestBestEffort {
4007            self.stats.dropped_best_effort_events += 1;
4008        }
4009        Ok(outcome)
4010    }
4011
4012    /// Drains events whose `target_tick` is ready for application.
4013    pub fn drain_ready(
4014        &mut self,
4015        station_id: StationId,
4016        current_tick: Tick,
4017    ) -> Result<Vec<StationEvent>, EventRouterError> {
4018        let mut ready = Vec::new();
4019        self.drain_ready_into(station_id, current_tick, &mut ready)?;
4020        Ok(ready)
4021    }
4022
4023    /// Drains ready events into caller-owned storage while retaining its capacity.
4024    pub fn drain_ready_into(
4025        &mut self,
4026        station_id: StationId,
4027        current_tick: Tick,
4028        ready: &mut Vec<StationEvent>,
4029    ) -> Result<(), EventRouterError> {
4030        ready.clear();
4031        self.append_ready(station_id, current_tick, ready)?;
4032        Ok(())
4033    }
4034
4035    fn append_ready(
4036        &mut self,
4037        station_id: StationId,
4038        current_tick: Tick,
4039        ready: &mut Vec<StationEvent>,
4040    ) -> Result<(), EventRouterError> {
4041        let queue = self
4042            .queues
4043            .get_mut(&station_id)
4044            .ok_or(EventRouterError::MissingTarget(station_id))?;
4045        let drained = queue.drain_ready_into(current_tick, ready);
4046        self.stats.drained_events = self.stats.drained_events.saturating_add(drained);
4047        Ok(())
4048    }
4049
4050    /// Returns queued event count for one station.
4051    pub fn queued_len(&self, station_id: StationId) -> Option<usize> {
4052        self.queues.get(&station_id).map(EventQueues::len)
4053    }
4054
4055    /// Returns router statistics.
4056    pub const fn stats(&self) -> EventRouterStats {
4057        self.stats
4058    }
4059}
4060
4061impl Default for EventRouter {
4062    fn default() -> Self {
4063        Self::new(EventQueueLimits::default())
4064    }
4065}
4066
4067/// Statistics for station event transport bridging.
4068#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
4069pub struct StationEventTransportStats {
4070    /// Events encoded and submitted to station transport.
4071    pub events_sent: usize,
4072    /// Bytes submitted to station transport.
4073    pub bytes_sent: usize,
4074    /// Packets received from station transport.
4075    pub packets_received: usize,
4076    /// Bytes received from station transport.
4077    pub bytes_received: usize,
4078    /// Events decoded and accepted by the target router.
4079    pub events_routed: usize,
4080}
4081
4082/// Result of pumping station event packets for one target.
4083#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
4084pub struct StationEventPumpReport {
4085    /// Target station pumped.
4086    pub target_station: StationId,
4087    /// Packets consumed from the station transport.
4088    pub packets_received: usize,
4089    /// Bytes consumed from the station transport.
4090    pub bytes_received: usize,
4091    /// Events accepted by the target router.
4092    pub events_routed: usize,
4093}
4094
4095/// Error produced while bridging station events through packet transport.
4096#[derive(Clone, Debug, PartialEq, Eq)]
4097pub enum StationEventTransportError<E> {
4098    /// Underlying station transport failed.
4099    Transport(E),
4100    /// Wire encoding failed.
4101    Encode(BinaryEncodeError),
4102    /// Wire decoding failed.
4103    Decode(BinaryDecodeError),
4104    /// Packet decoded as a non-event frame.
4105    UnexpectedFrame,
4106    /// Packet envelope and decoded event disagreed about endpoints.
4107    EndpointMismatch {
4108        /// Packet source station.
4109        packet_source: StationId,
4110        /// Packet target station.
4111        packet_target: StationId,
4112        /// Decoded event source station.
4113        event_source: StationId,
4114        /// Decoded event target station.
4115        event_target: StationId,
4116    },
4117    /// Event router rejected the decoded event.
4118    Router(EventRouterError),
4119}
4120
4121impl<E: core::fmt::Display> core::fmt::Display for StationEventTransportError<E> {
4122    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
4123        match self {
4124            Self::Transport(error) => write!(f, "{error}"),
4125            Self::Encode(error) => write!(f, "{error}"),
4126            Self::Decode(error) => write!(f, "{error}"),
4127            Self::UnexpectedFrame => f.write_str("station transport packet was not an event frame"),
4128            Self::EndpointMismatch {
4129                packet_source,
4130                packet_target,
4131                event_source,
4132                event_target,
4133            } => write!(
4134                f,
4135                "station event endpoint mismatch: packet {}->{}, event {}->{}",
4136                packet_source.get(),
4137                packet_target.get(),
4138                event_source.get(),
4139                event_target.get()
4140            ),
4141            Self::Router(error) => write!(f, "{error}"),
4142        }
4143    }
4144}
4145
4146impl<E> std::error::Error for StationEventTransportError<E>
4147where
4148    E: std::error::Error + 'static,
4149{
4150    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4151        match self {
4152            Self::Transport(error) => Some(error),
4153            Self::Encode(error) => Some(error),
4154            Self::Decode(error) => Some(error),
4155            Self::UnexpectedFrame | Self::EndpointMismatch { .. } => None,
4156            Self::Router(error) => Some(error),
4157        }
4158    }
4159}
4160
4161impl<E> From<BinaryEncodeError> for StationEventTransportError<E> {
4162    fn from(value: BinaryEncodeError) -> Self {
4163        Self::Encode(value)
4164    }
4165}
4166
4167impl<E> From<BinaryDecodeError> for StationEventTransportError<E> {
4168    fn from(value: BinaryDecodeError) -> Self {
4169        Self::Decode(value)
4170    }
4171}
4172
4173impl<E> From<EventRouterError> for StationEventTransportError<E> {
4174    fn from(value: EventRouterError) -> Self {
4175        Self::Router(value)
4176    }
4177}
4178
4179/// Bridge between typed station events and bounded station packet transport.
4180#[derive(Clone, Debug, Default)]
4181pub struct StationEventTransportBridge {
4182    stats: StationEventTransportStats,
4183}
4184
4185impl StationEventTransportBridge {
4186    /// Returns bridge statistics.
4187    pub const fn stats(&self) -> StationEventTransportStats {
4188        self.stats
4189    }
4190
4191    /// Encodes and sends one station event through the station transport.
4192    pub fn send_event<T>(
4193        &mut self,
4194        transport: &mut T,
4195        event: &StationEvent,
4196    ) -> Result<(), StationEventTransportError<T::Error>>
4197    where
4198        T: StationTransportSink,
4199    {
4200        let frame = StationEventFrame::from_event(event);
4201        let mut bytes = Vec::with_capacity(64);
4202        BinaryFrameEncoder.encode_station_event(&frame, &mut bytes)?;
4203        let byte_len = bytes.len();
4204        transport
4205            .send_station(StationOutboundPacket {
4206                source_station: event.source,
4207                target_station: event.target,
4208                bytes,
4209            })
4210            .map_err(StationEventTransportError::Transport)?;
4211        self.stats.events_sent = self.stats.events_sent.saturating_add(1);
4212        self.stats.bytes_sent = self.stats.bytes_sent.saturating_add(byte_len);
4213        Ok(())
4214    }
4215
4216    /// Receives up to `max_packets` for `target_station`, decodes station
4217    /// events, and routes them into `router`.
4218    pub fn pump_target<T>(
4219        &mut self,
4220        transport: &mut T,
4221        router: &mut EventRouter,
4222        target_station: StationId,
4223        max_packets: usize,
4224    ) -> Result<StationEventPumpReport, StationEventTransportError<T::Error>>
4225    where
4226        T: StationTransportReceiver,
4227    {
4228        let mut report = StationEventPumpReport {
4229            target_station,
4230            ..StationEventPumpReport::default()
4231        };
4232        for _ in 0..max_packets {
4233            let Some(packet) = transport
4234                .try_recv_station(target_station)
4235                .map_err(StationEventTransportError::Transport)?
4236            else {
4237                break;
4238            };
4239            report.packets_received = report.packets_received.saturating_add(1);
4240            report.bytes_received = report.bytes_received.saturating_add(packet.bytes.len());
4241
4242            let decoded = BinaryFrameDecoder.decode(&packet.bytes)?;
4243            let RuntimeFrame::StationEvent(frame) = decoded else {
4244                return Err(StationEventTransportError::UnexpectedFrame);
4245            };
4246            if frame.source_station != packet.source_station
4247                || frame.target_station != packet.target_station
4248            {
4249                return Err(StationEventTransportError::EndpointMismatch {
4250                    packet_source: packet.source_station,
4251                    packet_target: packet.target_station,
4252                    event_source: frame.source_station,
4253                    event_target: frame.target_station,
4254                });
4255            }
4256
4257            router.route(frame.into_event())?;
4258            report.events_routed = report.events_routed.saturating_add(1);
4259        }
4260
4261        self.stats.packets_received = self
4262            .stats
4263            .packets_received
4264            .saturating_add(report.packets_received);
4265        self.stats.bytes_received = self
4266            .stats
4267            .bytes_received
4268            .saturating_add(report.bytes_received);
4269        self.stats.events_routed = self
4270            .stats
4271            .events_routed
4272            .saturating_add(report.events_routed);
4273        Ok(report)
4274    }
4275}
4276
4277/// Statistics for command dispatch transport bridging.
4278#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
4279pub struct CommandDispatchTransportStats {
4280    /// Commands encoded and submitted to station transport.
4281    pub commands_sent: usize,
4282    /// Bytes submitted to station transport.
4283    pub bytes_sent: usize,
4284    /// Packets received from station transport.
4285    pub packets_received: usize,
4286    /// Bytes received from station transport.
4287    pub bytes_received: usize,
4288    /// Commands decoded and enqueued at the target station.
4289    pub commands_enqueued: usize,
4290    /// Commands rejected by target station queues.
4291    pub commands_rejected_queue: usize,
4292}
4293
4294/// Result of pumping command dispatch packets for one target.
4295#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
4296pub struct CommandDispatchPumpReport {
4297    /// Target station pumped.
4298    pub target_station: StationId,
4299    /// Packets consumed from station transport.
4300    pub packets_received: usize,
4301    /// Bytes consumed from station transport.
4302    pub bytes_received: usize,
4303    /// Commands enqueued into the target queue.
4304    pub commands_enqueued: usize,
4305}
4306
4307/// Error produced while bridging command dispatch frames through station packet transport.
4308#[derive(Clone, Debug, PartialEq, Eq)]
4309pub enum CommandDispatchTransportError<E> {
4310    /// Underlying station transport failed.
4311    Transport(E),
4312    /// Wire encoding failed.
4313    Encode(BinaryEncodeError),
4314    /// Wire decoding failed.
4315    Decode(BinaryDecodeError),
4316    /// Packet decoded as a non-command-dispatch frame.
4317    UnexpectedFrame,
4318    /// Packet envelope and decoded dispatch frame disagreed about the target.
4319    EndpointMismatch {
4320        /// Packet source station.
4321        packet_source: StationId,
4322        /// Packet target station.
4323        packet_target: StationId,
4324        /// Decoded command dispatch target station.
4325        dispatch_target: StationId,
4326    },
4327    /// Target station queue was not registered.
4328    MissingQueue(StationId),
4329    /// Target station queue rejected the command.
4330    Queue(CommandQueueError),
4331}
4332
4333impl<E: core::fmt::Display> core::fmt::Display for CommandDispatchTransportError<E> {
4334    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
4335        match self {
4336            Self::Transport(error) => write!(f, "{error}"),
4337            Self::Encode(error) => write!(f, "{error}"),
4338            Self::Decode(error) => write!(f, "{error}"),
4339            Self::UnexpectedFrame => {
4340                f.write_str("station transport packet was not a command dispatch frame")
4341            }
4342            Self::EndpointMismatch {
4343                packet_source,
4344                packet_target,
4345                dispatch_target,
4346            } => write!(
4347                f,
4348                "command dispatch endpoint mismatch: packet {}->{}, dispatch target {}",
4349                packet_source.get(),
4350                packet_target.get(),
4351                dispatch_target.get()
4352            ),
4353            Self::MissingQueue(station_id) => {
4354                write!(
4355                    f,
4356                    "command dispatch target station {} has no queue",
4357                    station_id.get()
4358                )
4359            }
4360            Self::Queue(error) => write!(f, "{error}"),
4361        }
4362    }
4363}
4364
4365impl<E> std::error::Error for CommandDispatchTransportError<E>
4366where
4367    E: std::error::Error + 'static,
4368{
4369    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4370        match self {
4371            Self::Transport(error) => Some(error),
4372            Self::Encode(error) => Some(error),
4373            Self::Decode(error) => Some(error),
4374            Self::UnexpectedFrame | Self::EndpointMismatch { .. } | Self::MissingQueue(_) => None,
4375            Self::Queue(error) => Some(error),
4376        }
4377    }
4378}
4379
4380impl<E> From<BinaryEncodeError> for CommandDispatchTransportError<E> {
4381    fn from(value: BinaryEncodeError) -> Self {
4382        Self::Encode(value)
4383    }
4384}
4385
4386impl<E> From<BinaryDecodeError> for CommandDispatchTransportError<E> {
4387    fn from(value: BinaryDecodeError) -> Self {
4388        Self::Decode(value)
4389    }
4390}
4391
4392impl<E> From<CommandQueueError> for CommandDispatchTransportError<E> {
4393    fn from(value: CommandQueueError) -> Self {
4394        Self::Queue(value)
4395    }
4396}
4397
4398/// Bridge between stamped command envelopes and bounded station packet transport.
4399#[derive(Clone, Debug, Default)]
4400pub struct CommandDispatchTransportBridge {
4401    stats: CommandDispatchTransportStats,
4402}
4403
4404impl CommandDispatchTransportBridge {
4405    /// Returns bridge statistics.
4406    pub const fn stats(&self) -> CommandDispatchTransportStats {
4407        self.stats
4408    }
4409
4410    /// Encodes and sends a stamped command envelope to a station node.
4411    pub fn send_envelope<T>(
4412        &mut self,
4413        transport: &mut T,
4414        source_station: StationId,
4415        target_station: StationId,
4416        command: &CommandEnvelope,
4417    ) -> Result<(), CommandDispatchTransportError<T::Error>>
4418    where
4419        T: StationTransportSink,
4420    {
4421        let mut bytes = Vec::with_capacity(64_usize.saturating_add(command.payload.len()));
4422        BinaryFrameEncoder.encode_command_dispatch_envelope(target_station, command, &mut bytes)?;
4423        self.send_encoded(transport, source_station, target_station, bytes)
4424    }
4425
4426    /// Encodes and sends a command dispatch frame to its target station.
4427    pub fn send_frame<T>(
4428        &mut self,
4429        transport: &mut T,
4430        source_station: StationId,
4431        frame: &CommandDispatchFrame,
4432    ) -> Result<(), CommandDispatchTransportError<T::Error>>
4433    where
4434        T: StationTransportSink,
4435    {
4436        let mut bytes = Vec::with_capacity(64);
4437        BinaryFrameEncoder.encode_command_dispatch(frame, &mut bytes)?;
4438        self.send_encoded(transport, source_station, frame.station_id, bytes)
4439    }
4440
4441    fn send_encoded<T>(
4442        &mut self,
4443        transport: &mut T,
4444        source_station: StationId,
4445        target_station: StationId,
4446        bytes: Vec<u8>,
4447    ) -> Result<(), CommandDispatchTransportError<T::Error>>
4448    where
4449        T: StationTransportSink,
4450    {
4451        let byte_len = bytes.len();
4452        transport
4453            .send_station(StationOutboundPacket {
4454                source_station,
4455                target_station,
4456                bytes,
4457            })
4458            .map_err(CommandDispatchTransportError::Transport)?;
4459        self.stats.commands_sent = self.stats.commands_sent.saturating_add(1);
4460        self.stats.bytes_sent = self.stats.bytes_sent.saturating_add(byte_len);
4461        Ok(())
4462    }
4463
4464    /// Receives up to `max_packets` for `target_station`, decodes command
4465    /// dispatch frames, and enqueues stamped commands into `station_queues`.
4466    pub fn pump_target<T>(
4467        &mut self,
4468        transport: &mut T,
4469        station_queues: &mut BTreeMap<StationId, CommandQueues>,
4470        target_station: StationId,
4471        max_packets: usize,
4472        ingress: CommandIngress,
4473    ) -> Result<CommandDispatchPumpReport, CommandDispatchTransportError<T::Error>>
4474    where
4475        T: StationTransportReceiver,
4476    {
4477        let mut report = CommandDispatchPumpReport {
4478            target_station,
4479            ..CommandDispatchPumpReport::default()
4480        };
4481        for _ in 0..max_packets {
4482            let Some(packet) = transport
4483                .try_recv_station(target_station)
4484                .map_err(CommandDispatchTransportError::Transport)?
4485            else {
4486                break;
4487            };
4488            report.packets_received = report.packets_received.saturating_add(1);
4489            report.bytes_received = report.bytes_received.saturating_add(packet.bytes.len());
4490
4491            let decoded = BinaryFrameDecoder.decode(&packet.bytes)?;
4492            let RuntimeFrame::CommandDispatch(frame) = decoded else {
4493                return Err(CommandDispatchTransportError::UnexpectedFrame);
4494            };
4495            if frame.station_id != packet.target_station {
4496                return Err(CommandDispatchTransportError::EndpointMismatch {
4497                    packet_source: packet.source_station,
4498                    packet_target: packet.target_station,
4499                    dispatch_target: frame.station_id,
4500                });
4501            }
4502
4503            let queue = station_queues.get_mut(&frame.station_id).ok_or(
4504                CommandDispatchTransportError::MissingQueue(frame.station_id),
4505            )?;
4506            match queue.push(frame.into_envelope(), ingress) {
4507                Ok(_) => {
4508                    report.commands_enqueued = report.commands_enqueued.saturating_add(1);
4509                }
4510                Err(error) => {
4511                    self.stats.commands_rejected_queue =
4512                        self.stats.commands_rejected_queue.saturating_add(1);
4513                    return Err(CommandDispatchTransportError::Queue(error));
4514                }
4515            }
4516        }
4517
4518        self.stats.packets_received = self
4519            .stats
4520            .packets_received
4521            .saturating_add(report.packets_received);
4522        self.stats.bytes_received = self
4523            .stats
4524            .bytes_received
4525            .saturating_add(report.bytes_received);
4526        self.stats.commands_enqueued = self
4527            .stats
4528            .commands_enqueued
4529            .saturating_add(report.commands_enqueued);
4530        Ok(report)
4531    }
4532}
4533
4534/// Budget for a load-aware scheduler step.
4535#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4536pub struct StationScheduleConfig {
4537    /// Maximum stations that may advance during one scheduler step.
4538    pub max_station_advances_per_step: usize,
4539}
4540
4541impl Default for StationScheduleConfig {
4542    fn default() -> Self {
4543        Self {
4544            max_station_advances_per_step: usize::MAX,
4545        }
4546    }
4547}
4548
4549/// Candidate selected by the load-aware station scheduler.
4550#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4551pub struct StationScheduleCandidate {
4552    /// Station selected for advancement.
4553    pub station_id: StationId,
4554    /// Deterministic pressure score derived from the latest load sample.
4555    pub load_score: u64,
4556    /// How far this station is behind the most advanced station in the set.
4557    pub tick_lag: u64,
4558}
4559
4560/// Result of one load-aware station scheduling pass.
4561/// Borrowed deterministic result from reusable Station scheduling storage.
4562#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4563pub struct StationScheduleView<'a> {
4564    /// Stations considered by this pass.
4565    pub candidates_considered: usize,
4566    /// Stations selected by this pass.
4567    pub stations_selected: usize,
4568    /// Station tick advances requested by this pass.
4569    pub total_advances: usize,
4570    /// Selected stations in deterministic execution order.
4571    pub selected: &'a [StationScheduleCandidate],
4572}
4573
4574/// Caller-owned reusable storage for load-aware Station scheduling.
4575///
4576/// Storage contains only derived pressure scores and stateless candidates. It
4577/// does not retain scheduling decisions, cooldowns, or gameplay state.
4578#[derive(Clone, Debug, Default)]
4579pub struct StationScheduleScratch {
4580    scores: HashMap<StationId, u64>,
4581    candidates: Vec<StationScheduleCandidate>,
4582}
4583
4584impl StationScheduleScratch {
4585    /// Creates empty scheduling scratch.
4586    pub fn new() -> Self {
4587        Self::default()
4588    }
4589
4590    /// Hash-table capacity retained for sampled Station scores.
4591    pub fn score_capacity(&self) -> usize {
4592        self.scores.capacity()
4593    }
4594
4595    /// Candidate capacity retained across scheduling passes.
4596    pub fn candidate_capacity(&self) -> usize {
4597        self.candidates.capacity()
4598    }
4599}
4600
4601/// Basic in-process station scheduler.
4602#[derive(Clone, Debug, Default)]
4603pub struct StationScheduler {
4604    /// Total station ticks advanced by this scheduler.
4605    pub advanced_ticks: u64,
4606}
4607
4608impl StationScheduler {
4609    /// Advances every station by one tick.
4610    pub fn advance_all(&mut self, stations: &mut StationSet) {
4611        for station in stations.iter_mut() {
4612            station.advance_tick();
4613            self.advanced_ticks = self.advanced_ticks.saturating_add(1);
4614        }
4615    }
4616
4617    /// Plans into caller-owned storage and returns a borrowed deterministic top-k view.
4618    pub fn plan_loaded_into<'a>(
4619        &self,
4620        stations: &StationSet,
4621        samples: &[StationLoadSample],
4622        config: StationScheduleConfig,
4623        scratch: &'a mut StationScheduleScratch,
4624    ) -> StationScheduleView<'a> {
4625        let candidates_considered = stations.len();
4626        let limit = config
4627            .max_station_advances_per_step
4628            .min(candidates_considered);
4629        let max_tick = stations
4630            .iter()
4631            .map(|station| station.tick().get())
4632            .max()
4633            .unwrap_or(0);
4634        scratch.scores.clear();
4635        scratch.scores.reserve(samples.len());
4636        for sample in samples {
4637            scratch
4638                .scores
4639                .insert(sample.station_id, station_schedule_score(sample));
4640        }
4641        scratch.candidates.clear();
4642        scratch.candidates.reserve(candidates_considered);
4643        scratch.candidates.extend(stations.iter().map(|station| {
4644            let station_id = station.config().station_id;
4645            StationScheduleCandidate {
4646                station_id,
4647                load_score: scratch.scores.get(&station_id).copied().unwrap_or(0),
4648                tick_lag: max_tick.saturating_sub(station.tick().get()),
4649            }
4650        }));
4651        prioritize_station_candidates(&mut scratch.candidates, limit);
4652        let selected = &scratch.candidates[..limit];
4653
4654        StationScheduleView {
4655            candidates_considered,
4656            stations_selected: selected.len(),
4657            total_advances: selected.len(),
4658            selected,
4659        }
4660    }
4661
4662    /// Advances a bounded top-k set using reusable caller-owned scheduling storage.
4663    pub fn advance_loaded_into<'a>(
4664        &mut self,
4665        stations: &mut StationSet,
4666        samples: &[StationLoadSample],
4667        config: StationScheduleConfig,
4668        scratch: &'a mut StationScheduleScratch,
4669    ) -> StationScheduleView<'a> {
4670        let plan = self.plan_loaded_into(stations, samples, config, scratch);
4671        for candidate in plan.selected {
4672            if let Some(station) = stations.get_mut(candidate.station_id) {
4673                station.advance_tick();
4674                self.advanced_ticks = self.advanced_ticks.saturating_add(1);
4675            }
4676        }
4677        plan
4678    }
4679
4680    /// Drains all Station-ready events into reusable caller-owned output.
4681    pub fn drain_ready_events_into(
4682        &mut self,
4683        stations: &StationSet,
4684        router: &mut EventRouter,
4685        events: &mut Vec<StationEvent>,
4686    ) -> Result<(), EventRouterError> {
4687        events.clear();
4688        for station in stations.iter() {
4689            router.append_ready(station.config().station_id, station.tick(), events)?;
4690        }
4691        Ok(())
4692    }
4693}
4694
4695fn compare_station_schedule_candidates(
4696    left: &StationScheduleCandidate,
4697    right: &StationScheduleCandidate,
4698) -> core::cmp::Ordering {
4699    right
4700        .load_score
4701        .cmp(&left.load_score)
4702        .then_with(|| right.tick_lag.cmp(&left.tick_lag))
4703        .then_with(|| left.station_id.cmp(&right.station_id))
4704}
4705
4706fn prioritize_station_candidates(candidates: &mut [StationScheduleCandidate], limit: usize) {
4707    if limit == 0 {
4708        return;
4709    }
4710    if limit.saturating_mul(2) < candidates.len() {
4711        candidates.select_nth_unstable_by(limit, compare_station_schedule_candidates);
4712        candidates[..limit].sort_by(compare_station_schedule_candidates);
4713    } else {
4714        candidates.sort_by(compare_station_schedule_candidates);
4715    }
4716}
4717
4718fn station_schedule_score(sample: &StationLoadSample) -> u64 {
4719    station_load_score(sample).saturating_add(sample.max_cell_pressure())
4720}
4721
4722/// Per-station progress inside a full runtime barrier.
4723#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4724pub enum StationBarrierPhase {
4725    /// Station is part of the barrier but has not reached the target tick.
4726    WaitingTick,
4727    /// Station reached the target tick and is frozen.
4728    Frozen,
4729    /// Station has resumed.
4730    Resumed,
4731}
4732
4733/// Barrier progress summary.
4734#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4735pub struct BarrierProgress {
4736    /// Barrier state.
4737    pub state: BarrierState,
4738    /// Number of stations covered by the barrier.
4739    pub station_count: usize,
4740    /// Number of stations frozen.
4741    pub frozen_count: usize,
4742    /// Target tick selected for the barrier.
4743    pub target_tick: Tick,
4744}
4745
4746/// Runtime barrier metrics.
4747#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
4748pub struct BarrierMetrics {
4749    /// Number of stations covered by this barrier.
4750    pub station_count: usize,
4751    /// Number of snapshots exported while frozen.
4752    pub snapshots_exported: usize,
4753    /// Number of times polling observed at least one station still waiting.
4754    pub waiting_polls: u64,
4755    /// Number of times polling observed a fully frozen barrier.
4756    pub frozen_polls: u64,
4757}
4758
4759/// Runtime barrier execution error.
4760#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4761pub enum BarrierRuntimeError {
4762    /// A barrier is already active.
4763    AlreadyActive(BarrierId),
4764    /// No barrier is active.
4765    NoActiveBarrier,
4766    /// Barrier scope matched no stations.
4767    EmptyScope(BarrierScope),
4768    /// Requested operation requires frozen state.
4769    NotFrozen(BarrierState),
4770    /// A station covered by the barrier is missing.
4771    MissingStation(StationId),
4772}
4773
4774/// Caller-owned reusable station snapshot slots for frozen barrier exports.
4775#[derive(Clone, Debug, Default)]
4776pub struct BarrierSnapshotScratch {
4777    snapshots: Vec<StationSnapshot>,
4778    active_snapshots: usize,
4779}
4780
4781impl BarrierSnapshotScratch {
4782    /// Creates empty barrier snapshot storage.
4783    pub fn new() -> Self {
4784        Self::default()
4785    }
4786
4787    /// Reserves Station slots and per-Station entity capacity.
4788    pub fn reserve(&mut self, stations: usize, entities_per_station: usize) {
4789        if self.snapshots.len() < stations {
4790            self.snapshots
4791                .resize_with(stations, StationSnapshot::default);
4792        }
4793        for snapshot in &mut self.snapshots[..stations] {
4794            if snapshot.entities.capacity() < entities_per_station {
4795                snapshot
4796                    .entities
4797                    .reserve(entities_per_station.saturating_sub(snapshot.entities.len()));
4798            }
4799        }
4800    }
4801
4802    /// Snapshot slots retained across exports.
4803    pub fn retained_snapshot_slots(&self) -> usize {
4804        self.snapshots.len()
4805    }
4806
4807    /// Total entity capacity retained across Station snapshot slots.
4808    pub fn retained_entity_capacity(&self) -> usize {
4809        self.snapshots
4810            .iter()
4811            .map(|snapshot| snapshot.entities.capacity())
4812            .sum()
4813    }
4814
4815    fn prepare(&mut self, stations: usize) {
4816        if self.snapshots.len() < stations {
4817            self.snapshots
4818                .resize_with(stations, StationSnapshot::default);
4819        }
4820        self.active_snapshots = stations;
4821    }
4822
4823    fn active(&self) -> &[StationSnapshot] {
4824        &self.snapshots[..self.active_snapshots]
4825    }
4826}
4827
4828impl core::fmt::Display for BarrierRuntimeError {
4829    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
4830        match self {
4831            Self::AlreadyActive(id) => write!(f, "barrier {} is already active", id.get()),
4832            Self::NoActiveBarrier => f.write_str("no active barrier"),
4833            Self::EmptyScope(scope) => write!(f, "barrier scope {scope:?} contains no stations"),
4834            Self::NotFrozen(state) => {
4835                write!(f, "barrier operation requires Frozen state, got {state:?}")
4836            }
4837            Self::MissingStation(id) => write!(f, "barrier station {} is missing", id.get()),
4838        }
4839    }
4840}
4841
4842impl std::error::Error for BarrierRuntimeError {}
4843
4844/// Full runtime barrier executor for in-process station sets.
4845#[derive(Clone, Debug, Default)]
4846pub struct BarrierController {
4847    active: Option<RuntimeBarrier>,
4848    phases: BTreeMap<StationId, StationBarrierPhase>,
4849    metrics: BarrierMetrics,
4850}
4851
4852impl BarrierController {
4853    /// Returns the active barrier, if any.
4854    pub const fn active(&self) -> Option<RuntimeBarrier> {
4855        self.active
4856    }
4857
4858    /// Requests a barrier over stations matching `scope`.
4859    pub fn request(
4860        &mut self,
4861        stations: &StationSet,
4862        id: BarrierId,
4863        scope: BarrierScope,
4864        target_tick: Tick,
4865        command_mode: CommandQueueMode,
4866    ) -> Result<BarrierProgress, BarrierRuntimeError> {
4867        if let Some(active) = self.active {
4868            return Err(BarrierRuntimeError::AlreadyActive(active.id));
4869        }
4870
4871        let station_ids = stations.station_ids_in_scope(scope);
4872        if station_ids.is_empty() {
4873            return Err(BarrierRuntimeError::EmptyScope(scope));
4874        }
4875
4876        let requested_at = station_ids
4877            .iter()
4878            .filter_map(|station_id| stations.get(*station_id).map(Station::tick))
4879            .map(Tick::get)
4880            .max()
4881            .map_or(Tick::new(0), Tick::new);
4882
4883        let mut barrier =
4884            RuntimeBarrier::requested(id, scope, requested_at, target_tick, command_mode);
4885        barrier.wait_for_tick_boundary();
4886
4887        self.metrics = BarrierMetrics {
4888            station_count: station_ids.len(),
4889            ..BarrierMetrics::default()
4890        };
4891        self.phases.clear();
4892        for station_id in station_ids {
4893            self.phases
4894                .insert(station_id, StationBarrierPhase::WaitingTick);
4895        }
4896        self.active = Some(barrier);
4897
4898        Ok(self.progress())
4899    }
4900
4901    /// Polls station ticks and freezes the barrier once all covered stations are aligned.
4902    pub fn poll(&mut self, stations: &StationSet) -> Result<BarrierProgress, BarrierRuntimeError> {
4903        let Some(mut barrier) = self.active else {
4904            return Err(BarrierRuntimeError::NoActiveBarrier);
4905        };
4906
4907        if matches!(barrier.state, BarrierState::Frozen) {
4908            self.metrics.frozen_polls = self.metrics.frozen_polls.saturating_add(1);
4909            return Ok(self.progress());
4910        }
4911
4912        let mut all_ready = true;
4913        for (station_id, phase) in &mut self.phases {
4914            let station = stations
4915                .get(*station_id)
4916                .ok_or(BarrierRuntimeError::MissingStation(*station_id))?;
4917            if station.tick() >= barrier.target_tick {
4918                *phase = StationBarrierPhase::Frozen;
4919            } else {
4920                all_ready = false;
4921            }
4922        }
4923
4924        if all_ready {
4925            barrier.freeze();
4926            self.active = Some(barrier);
4927            self.metrics.frozen_polls = self.metrics.frozen_polls.saturating_add(1);
4928        } else {
4929            self.metrics.waiting_polls = self.metrics.waiting_polls.saturating_add(1);
4930        }
4931
4932        Ok(self.progress())
4933    }
4934
4935    /// Exports station snapshots while the barrier is frozen.
4936    pub fn export_snapshots(
4937        &mut self,
4938        stations: &StationSet,
4939        version: SnapshotVersion,
4940    ) -> Result<Vec<StationSnapshot>, BarrierRuntimeError> {
4941        let barrier = self.active.ok_or(BarrierRuntimeError::NoActiveBarrier)?;
4942        if barrier.state != BarrierState::Frozen {
4943            return Err(BarrierRuntimeError::NotFrozen(barrier.state));
4944        }
4945
4946        let mut snapshots = Vec::with_capacity(self.phases.len());
4947        for station_id in self.phases.keys().copied() {
4948            let station = stations
4949                .get(station_id)
4950                .ok_or(BarrierRuntimeError::MissingStation(station_id))?;
4951            snapshots.push(station.snapshot(version));
4952        }
4953        self.metrics.snapshots_exported = self
4954            .metrics
4955            .snapshots_exported
4956            .saturating_add(snapshots.len());
4957        Ok(snapshots)
4958    }
4959
4960    /// Exports frozen snapshots into caller-owned reusable Station slots.
4961    pub fn export_snapshots_into<'a>(
4962        &mut self,
4963        stations: &StationSet,
4964        version: SnapshotVersion,
4965        scratch: &'a mut BarrierSnapshotScratch,
4966    ) -> Result<&'a [StationSnapshot], BarrierRuntimeError> {
4967        let barrier = self.active.ok_or(BarrierRuntimeError::NoActiveBarrier)?;
4968        if barrier.state != BarrierState::Frozen {
4969            return Err(BarrierRuntimeError::NotFrozen(barrier.state));
4970        }
4971
4972        scratch.prepare(self.phases.len());
4973        for (snapshot, station_id) in scratch.snapshots[..scratch.active_snapshots]
4974            .iter_mut()
4975            .zip(self.phases.keys().copied())
4976        {
4977            let station = stations
4978                .get(station_id)
4979                .ok_or(BarrierRuntimeError::MissingStation(station_id))?;
4980            station.snapshot_into(version, snapshot);
4981        }
4982        self.metrics.snapshots_exported = self
4983            .metrics
4984            .snapshots_exported
4985            .saturating_add(scratch.active_snapshots);
4986        Ok(scratch.active())
4987    }
4988
4989    /// Resumes all stations covered by the barrier and returns final metrics.
4990    pub fn resume(&mut self) -> Result<BarrierMetrics, BarrierRuntimeError> {
4991        let Some(mut barrier) = self.active else {
4992            return Err(BarrierRuntimeError::NoActiveBarrier);
4993        };
4994        if barrier.state != BarrierState::Frozen {
4995            return Err(BarrierRuntimeError::NotFrozen(barrier.state));
4996        }
4997
4998        barrier.resume();
4999        for phase in self.phases.values_mut() {
5000            *phase = StationBarrierPhase::Resumed;
5001        }
5002        barrier.finish();
5003        let metrics = self.metrics;
5004        self.active = None;
5005        self.phases.clear();
5006        self.metrics = BarrierMetrics::default();
5007        Ok(metrics)
5008    }
5009
5010    /// Returns current barrier progress.
5011    pub fn progress(&self) -> BarrierProgress {
5012        let state = self
5013            .active
5014            .map_or(BarrierState::Running, |barrier| barrier.state);
5015        let target_tick = self
5016            .active
5017            .map_or(Tick::new(0), |barrier| barrier.target_tick);
5018        let frozen_count = self
5019            .phases
5020            .values()
5021            .filter(|phase| matches!(phase, StationBarrierPhase::Frozen))
5022            .count();
5023
5024        BarrierProgress {
5025            state,
5026            station_count: self.phases.len(),
5027            frozen_count,
5028            target_tick,
5029        }
5030    }
5031}
5032
5033/// Report produced after applying an external upgrade hook to frozen snapshots.
5034#[derive(Clone, Debug, PartialEq, Eq)]
5035pub struct BarrierUpgradeReport {
5036    /// Snapshot version requested for export.
5037    pub version: SnapshotVersion,
5038    /// Snapshots passed through the upgrade hook.
5039    pub snapshots_migrated: usize,
5040    /// Stations restored from migrated snapshots.
5041    pub stations_restored: usize,
5042    /// Entity records restored across all stations.
5043    pub entities_restored: usize,
5044}
5045
5046/// Error produced while applying an upgrade hook around a frozen barrier.
5047#[derive(Clone, Debug, PartialEq, Eq)]
5048pub enum BarrierUpgradeError {
5049    /// Barrier was missing or not frozen.
5050    Barrier(BarrierRuntimeError),
5051    /// Station disappeared between snapshot export and restore.
5052    MissingStation(StationId),
5053    /// Restoring a migrated snapshot failed.
5054    Restore {
5055        /// Station being restored.
5056        station_id: StationId,
5057        /// Restore error.
5058        error: StationError,
5059    },
5060}
5061
5062impl core::fmt::Display for BarrierUpgradeError {
5063    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
5064        match self {
5065            Self::Barrier(error) => write!(f, "{error}"),
5066            Self::MissingStation(station_id) => {
5067                write!(f, "upgrade station {} is missing", station_id.get())
5068            }
5069            Self::Restore { station_id, error } => {
5070                write!(
5071                    f,
5072                    "upgrade restore for station {} failed: {error}",
5073                    station_id.get()
5074                )
5075            }
5076        }
5077    }
5078}
5079
5080impl std::error::Error for BarrierUpgradeError {
5081    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5082        match self {
5083            Self::Barrier(error) => Some(error),
5084            Self::Restore { error, .. } => Some(error),
5085            Self::MissingStation(_) => None,
5086        }
5087    }
5088}
5089
5090impl From<BarrierRuntimeError> for BarrierUpgradeError {
5091    fn from(value: BarrierRuntimeError) -> Self {
5092        Self::Barrier(value)
5093    }
5094}
5095
5096/// Applies an external in-memory upgrade hook while a runtime barrier is frozen.
5097#[derive(Clone, Copy, Debug, Default)]
5098pub struct BarrierUpgradeExecutor;
5099
5100impl BarrierUpgradeExecutor {
5101    /// Exports frozen station snapshots, lets `hook` migrate them, and restores
5102    /// every station only after all migrated snapshots are valid.
5103    pub fn migrate_frozen<H>(
5104        controller: &mut BarrierController,
5105        stations: &mut StationSet,
5106        version: SnapshotVersion,
5107        hook: &mut H,
5108    ) -> Result<BarrierUpgradeReport, BarrierUpgradeError>
5109    where
5110        H: RuntimeUpgradeHook,
5111    {
5112        let report_version = version;
5113        let snapshots = controller.export_snapshots(stations, version)?;
5114        let mut restored = Vec::with_capacity(snapshots.len());
5115        let mut entities_restored = 0usize;
5116
5117        for snapshot in snapshots {
5118            let station_id = snapshot.meta.station_id;
5119            let config = stations
5120                .get(station_id)
5121                .ok_or(BarrierUpgradeError::MissingStation(station_id))?
5122                .config();
5123            hook.pre_upgrade(&snapshot.meta);
5124            let migrated = hook.migrate_state(snapshot);
5125            let migrated_meta = migrated.meta.clone();
5126            let restored_station = Station::restore(config, migrated)
5127                .map_err(|error| BarrierUpgradeError::Restore { station_id, error })?;
5128            entities_restored = entities_restored.saturating_add(restored_station.len());
5129            hook.post_upgrade(&migrated_meta);
5130            restored.push((station_id, restored_station));
5131        }
5132
5133        let stations_restored = restored.len();
5134        for (station_id, restored_station) in restored {
5135            let station = stations
5136                .get_mut(station_id)
5137                .ok_or(BarrierUpgradeError::MissingStation(station_id))?;
5138            *station = restored_station;
5139        }
5140
5141        Ok(BarrierUpgradeReport {
5142            version: report_version,
5143            snapshots_migrated: stations_restored,
5144            stations_restored,
5145            entities_restored,
5146        })
5147    }
5148}
5149
5150#[cfg(test)]
5151mod tests {
5152    use super::*;
5153    use sectorsync_core::prelude::{
5154        Bounds, CellCoord3, CellLoadSample, CommandEnvelope, CommandPriority, CommandQueueLimits,
5155        ComponentId, EventId, EventKind, EventPriority, GatewayConfig, GridSpec, HotspotThresholds,
5156        InstanceId, NodeId, PolicyId, Position3, SnapshotMeta, StationConfig, StationLoadSample,
5157    };
5158    use sectorsync_transport::{
5159        ClientTransportLimits, InMemoryStationTransport, InMemoryTransportHub, OutboundPacket,
5160        StationOutboundPacket, StationTransportSink, TransportReceiver, TransportSink,
5161    };
5162    use sectorsync_wire::{
5163        BarrierFrame, BinaryFrameDecoder, BinaryFrameEncoder, CommandAckFrame,
5164        CommandDispatchFrame, CommandFrame, ComponentDelta, EntityDelta, FrameDecoder,
5165        FrameEncoder, ReplicationFrame,
5166    };
5167
5168    fn station(station_id: u32, instance_id: u64) -> Station {
5169        Station::new(StationConfig {
5170            station_id: StationId::new(station_id),
5171            node_id: NodeId::new(0),
5172            instance_id: InstanceId::new(instance_id),
5173            tick_rate_hz: 20,
5174        })
5175    }
5176
5177    fn encode_command_frame(sequence: u64) -> Vec<u8> {
5178        let frame = CommandFrame {
5179            client_id: ClientId::new(7),
5180            command_id: CommandId::new(sequence),
5181            entity_id: EntityId::new(100),
5182            sequence,
5183            kind: 1,
5184            priority: CommandPriority::High,
5185            payload: b"move:north".to_vec(),
5186        };
5187        let mut bytes = Vec::new();
5188        BinaryFrameEncoder
5189            .encode_command(&frame, &mut bytes)
5190            .expect("command should encode");
5191        bytes
5192    }
5193
5194    fn command_queues() -> CommandQueues {
5195        CommandQueues::new(CommandQueueLimits {
5196            high: 4,
5197            normal: 4,
5198            low: 4,
5199        })
5200    }
5201
5202    fn gateway(max_commands_per_tick: usize) -> GatewaySessionTable {
5203        GatewaySessionTable::new(GatewayConfig {
5204            max_sessions: 8,
5205            reconnect_grace_ticks: 10,
5206            max_commands_per_tick,
5207        })
5208    }
5209
5210    #[test]
5211    fn station_set_indexes_first_slot_and_reserves_both_storage_classes() {
5212        let mut stations = StationSet::with_capacity(3);
5213        let mut duplicate = station(1, 99);
5214        duplicate.advance_tick();
5215        stations.push(station(1, 10));
5216        stations.push(duplicate);
5217        stations.push(station(2, 10));
5218
5219        assert!(stations.station_capacity() >= 3);
5220        assert!(!stations.lookup_index_active());
5221        assert_eq!(
5222            stations
5223                .get(StationId::new(1))
5224                .expect("first exists")
5225                .tick(),
5226            Tick::new(0)
5227        );
5228        let (first, second) = stations
5229            .get_pair_mut(StationId::new(1), StationId::new(2))
5230            .expect("distinct indexed Stations should borrow");
5231        first.advance_tick();
5232        second.advance_tick();
5233        assert_eq!(
5234            stations
5235                .get(StationId::new(1))
5236                .expect("first exists")
5237                .tick(),
5238            Tick::new(1)
5239        );
5240        assert_eq!(
5241            stations
5242                .get(StationId::new(2))
5243                .expect("second exists")
5244                .tick(),
5245            Tick::new(1)
5246        );
5247
5248        let lookup_capacity = stations.lookup_capacity();
5249        stations.reserve(4);
5250        assert!(stations.station_capacity() >= stations.len().saturating_add(4));
5251        assert!(stations.lookup_capacity() >= lookup_capacity);
5252    }
5253
5254    #[test]
5255    fn station_index_set_replaces_in_place_and_indexes_mutable_pairs() {
5256        let grid = GridSpec::new(10.0).expect("grid should build");
5257        let first_id = StationId::new(1);
5258        let second_id = StationId::new(2);
5259        let first_handle = EntityHandle::new(1, 0);
5260        let second_handle = EntityHandle::new(2, 0);
5261        let mut indexes = StationIndexSet::with_capacity(2);
5262        indexes.insert(first_id, CellIndex::new(grid));
5263        indexes.insert(second_id, CellIndex::new(grid));
5264
5265        let mut replacement = CellIndex::new(grid);
5266        replacement.upsert(first_handle, Position3::new(1.0, 0.0, 0.0), Bounds::Point);
5267        indexes.insert(first_id, replacement);
5268        assert_eq!(indexes.len(), 2);
5269        assert_eq!(
5270            indexes.iter().map(|(id, _)| id).collect::<Vec<_>>(),
5271            vec![first_id, second_id]
5272        );
5273        assert_eq!(
5274            indexes
5275                .get(first_id)
5276                .expect("first index exists")
5277                .entity_count(),
5278            1
5279        );
5280
5281        let (first, second) = indexes
5282            .get_pair_mut(first_id, second_id)
5283            .expect("distinct indexed cells should borrow");
5284        first.remove(first_handle);
5285        second.upsert(second_handle, Position3::new(11.0, 0.0, 0.0), Bounds::Point);
5286        assert_eq!(
5287            indexes
5288                .get(first_id)
5289                .expect("first index exists")
5290                .entity_count(),
5291            0
5292        );
5293        assert_eq!(
5294            indexes
5295                .get(second_id)
5296                .expect("second index exists")
5297                .entity_count(),
5298            1
5299        );
5300        assert!(indexes.index_capacity() >= 2);
5301        assert!(!indexes.lookup_index_active());
5302    }
5303
5304    #[test]
5305    fn station_registries_activate_lookup_index_at_adaptive_threshold() {
5306        let grid = GridSpec::new(10.0).expect("grid should build");
5307        let mut stations = StationSet::with_capacity(STATION_LOOKUP_INDEX_THRESHOLD);
5308        let mut indexes = StationIndexSet::with_capacity(STATION_LOOKUP_INDEX_THRESHOLD);
5309        for raw_id in 1..=STATION_LOOKUP_INDEX_THRESHOLD {
5310            let station_id = StationId::new(u32::try_from(raw_id).expect("threshold fits u32"));
5311            stations.push(station(station_id.get(), 10));
5312            indexes.insert(station_id, CellIndex::new(grid));
5313            if raw_id < STATION_LOOKUP_INDEX_THRESHOLD {
5314                assert!(!stations.lookup_index_active());
5315                assert!(!indexes.lookup_index_active());
5316            }
5317        }
5318
5319        assert!(stations.lookup_index_active());
5320        assert!(indexes.lookup_index_active());
5321        assert!(stations.lookup_capacity() >= STATION_LOOKUP_INDEX_THRESHOLD);
5322        assert!(indexes.lookup_capacity() >= STATION_LOOKUP_INDEX_THRESHOLD);
5323        let last = StationId::new(
5324            u32::try_from(STATION_LOOKUP_INDEX_THRESHOLD).expect("threshold fits u32"),
5325        );
5326        assert_eq!(
5327            stations
5328                .get(last)
5329                .expect("last Station exists")
5330                .config()
5331                .station_id,
5332            last
5333        );
5334        assert!(indexes.get(last).is_some());
5335
5336        let removed_id = StationId::new(2);
5337        let removed_station = stations.remove(removed_id).expect("Station should remove");
5338        let removed_index = indexes.remove(removed_id).expect("index should remove");
5339        assert_eq!(removed_station.config().station_id, removed_id);
5340        assert_eq!(removed_index.entity_count(), 0);
5341        assert!(stations.get(removed_id).is_none());
5342        assert!(indexes.get(removed_id).is_none());
5343        assert_eq!(
5344            stations
5345                .get(last)
5346                .expect("shifted Station resolves")
5347                .config()
5348                .station_id,
5349            last
5350        );
5351        assert!(indexes.get(last).is_some());
5352        assert_eq!(
5353            stations
5354                .iter()
5355                .map(|station| station.config().station_id)
5356                .nth(1),
5357            Some(StationId::new(3))
5358        );
5359        assert_eq!(
5360            indexes.iter().nth(1).map(|(id, _)| id),
5361            Some(StationId::new(3))
5362        );
5363        assert!(stations.lookup_index_active());
5364        assert!(indexes.lookup_index_active());
5365    }
5366
5367    #[test]
5368    fn barrier_freezes_snapshots_and_resumes_instance_scope() {
5369        let mut stations = StationSet::default();
5370        stations.push(station(1, 10));
5371        stations.push(station(2, 10));
5372
5373        for station in stations.iter_mut() {
5374            station.advance_tick();
5375            station.advance_tick();
5376        }
5377
5378        let mut controller = BarrierController::default();
5379        let requested = controller
5380            .request(
5381                &stations,
5382                BarrierId::new(7),
5383                BarrierScope::Instance(InstanceId::new(10)),
5384                Tick::new(2),
5385                CommandQueueMode::Buffer,
5386            )
5387            .expect("request should work");
5388        assert_eq!(requested.state, BarrierState::WaitingTickBoundary);
5389
5390        let frozen = controller.poll(&stations).expect("poll should work");
5391        assert_eq!(frozen.state, BarrierState::Frozen);
5392        assert_eq!(frozen.frozen_count, 2);
5393
5394        let mut scratch = BarrierSnapshotScratch::new();
5395        scratch.reserve(2, 1);
5396        let snapshots = controller
5397            .export_snapshots_into(&stations, SnapshotVersion::default(), &mut scratch)
5398            .expect("reusable snapshot should work while frozen");
5399        assert_eq!(snapshots.len(), 2);
5400        let retained_slots = scratch.retained_snapshot_slots();
5401        let retained_entities = scratch.retained_entity_capacity();
5402        scratch.reserve(2, 1);
5403        let snapshots = controller
5404            .export_snapshots_into(&stations, SnapshotVersion::default(), &mut scratch)
5405            .expect("second reusable snapshot should work while frozen");
5406        assert_eq!(snapshots.len(), 2);
5407        assert_eq!(scratch.retained_snapshot_slots(), retained_slots);
5408        assert_eq!(scratch.retained_entity_capacity(), retained_entities);
5409
5410        let metrics = controller.resume().expect("resume should work");
5411        assert_eq!(metrics.station_count, 2);
5412        assert_eq!(metrics.snapshots_exported, 4);
5413        assert_eq!(controller.progress().state, BarrierState::Running);
5414    }
5415
5416    #[derive(Default)]
5417    struct MoveSnapshotUpgrade {
5418        pre: usize,
5419        migrations: usize,
5420        post: usize,
5421    }
5422
5423    impl RuntimeUpgradeHook for MoveSnapshotUpgrade {
5424        fn pre_upgrade(&mut self, meta: &SnapshotMeta) {
5425            self.pre = self.pre.saturating_add(1);
5426            assert_eq!(meta.version.runtime_version, 2);
5427        }
5428
5429        fn migrate_state(&mut self, mut snapshot: StationSnapshot) -> StationSnapshot {
5430            self.migrations = self.migrations.saturating_add(1);
5431            for entity in &mut snapshot.entities {
5432                entity.position.x += 10.0;
5433            }
5434            snapshot
5435        }
5436
5437        fn post_upgrade(&mut self, meta: &SnapshotMeta) {
5438            self.post = self.post.saturating_add(1);
5439            assert_eq!(meta.version.runtime_version, 2);
5440        }
5441    }
5442
5443    #[test]
5444    fn barrier_upgrade_executor_migrates_and_restores_frozen_snapshots() {
5445        let mut first = station(1, 10);
5446        first
5447            .spawn_owned(
5448                EntityId::new(100),
5449                Position3::new(1.0, 2.0, 3.0),
5450                Bounds::Point,
5451                PolicyId::new(0),
5452            )
5453            .expect("spawn should work");
5454        let mut stations = StationSet::default();
5455        stations.push(first);
5456        stations.push(station(2, 10));
5457
5458        for station in stations.iter_mut() {
5459            station.advance_tick();
5460            station.advance_tick();
5461        }
5462
5463        let mut controller = BarrierController::default();
5464        controller
5465            .request(
5466                &stations,
5467                BarrierId::new(8),
5468                BarrierScope::Instance(InstanceId::new(10)),
5469                Tick::new(2),
5470                CommandQueueMode::Buffer,
5471            )
5472            .expect("request should work");
5473        assert_eq!(
5474            controller.poll(&stations).expect("poll should work").state,
5475            BarrierState::Frozen
5476        );
5477
5478        let mut hook = MoveSnapshotUpgrade::default();
5479        let version = SnapshotVersion {
5480            runtime_version: 2,
5481            ..SnapshotVersion::default()
5482        };
5483        let report = BarrierUpgradeExecutor::migrate_frozen(
5484            &mut controller,
5485            &mut stations,
5486            version,
5487            &mut hook,
5488        )
5489        .expect("upgrade should migrate frozen snapshots");
5490
5491        assert_eq!(report.version, version);
5492        assert_eq!(report.snapshots_migrated, 2);
5493        assert_eq!(report.stations_restored, 2);
5494        assert_eq!(report.entities_restored, 1);
5495        assert_eq!(hook.pre, 2);
5496        assert_eq!(hook.migrations, 2);
5497        assert_eq!(hook.post, 2);
5498        let moved = stations
5499            .get(StationId::new(1))
5500            .expect("station should exist")
5501            .get_by_id(EntityId::new(100))
5502            .expect("entity should restore");
5503        assert_eq!(moved.position, Position3::new(11.0, 2.0, 3.0));
5504        assert_eq!(controller.progress().state, BarrierState::Frozen);
5505
5506        let metrics = controller.resume().expect("resume should work");
5507        assert_eq!(metrics.snapshots_exported, 2);
5508        assert_eq!(controller.progress().state, BarrierState::Running);
5509    }
5510
5511    #[test]
5512    fn barrier_transport_bridge_broadcasts_client_notifications() {
5513        let server_id = ClientId::new(0);
5514        let clients = [ClientId::new(7), ClientId::new(8)];
5515        let hub = InMemoryTransportHub::new(ClientTransportLimits {
5516            max_queued_packets_per_client: 4,
5517            max_packet_bytes: 512,
5518        });
5519        let mut server_transport = hub
5520            .endpoint(server_id, "127.0.0.1:23400".parse().expect("server addr"))
5521            .expect("server endpoint should register");
5522        let mut client_transports = clients
5523            .into_iter()
5524            .enumerate()
5525            .map(|(index, client_id)| {
5526                hub.endpoint(
5527                    client_id,
5528                    format!("127.0.0.1:{}", 23407 + index)
5529                        .parse()
5530                        .expect("client addr"),
5531                )
5532                .expect("client endpoint should register")
5533            })
5534            .collect::<Vec<_>>();
5535        let mut barrier = RuntimeBarrier::requested(
5536            BarrierId::new(5),
5537            BarrierScope::Instance(InstanceId::new(10)),
5538            Tick::new(10),
5539            Tick::new(12),
5540            CommandQueueMode::Buffer,
5541        );
5542        barrier.wait_for_tick_boundary();
5543        barrier.freeze();
5544
5545        let mut bridge = BarrierTransportBridge::default();
5546        let report = bridge
5547            .broadcast_barrier(&mut server_transport, clients, barrier)
5548            .expect("barrier should broadcast");
5549
5550        assert_eq!(report.barrier_id, barrier.id);
5551        assert_eq!(report.state, BarrierState::Frozen);
5552        assert_eq!(report.server_tick, Tick::new(12));
5553        assert_eq!(report.clients_requested, 2);
5554        assert_eq!(report.clients_sent, 2);
5555        assert!(report.bytes_sent > 0);
5556        assert_eq!(bridge.stats().notifications_sent, 2);
5557        assert_eq!(bridge.stats().clients_notified, 2);
5558        assert_eq!(bridge.stats().bytes_sent, report.bytes_sent);
5559
5560        for (index, client_id) in clients.into_iter().enumerate() {
5561            let mut client_bridge = ClientTransportBridge::new(
5562                ClientTransportConfig::new(client_id, server_id).with_expected_source(server_id),
5563            );
5564            let pump = client_bridge
5565                .pump_owned(&mut client_transports[index], 2)
5566                .expect("client should receive barrier");
5567            assert_eq!(pump.barrier_frames_received(), 1);
5568            assert_eq!(
5569                pump.barriers[0],
5570                BarrierFrame {
5571                    client_id,
5572                    barrier_id: barrier.id,
5573                    server_tick: barrier.target_tick,
5574                    state: BarrierState::Frozen,
5575                }
5576            );
5577        }
5578    }
5579
5580    #[test]
5581    fn replication_receive_bridge_decodes_target_frames() {
5582        let client_id = ClientId::new(7);
5583        let server_id = ClientId::new(0);
5584        let hub = InMemoryTransportHub::new(ClientTransportLimits {
5585            max_queued_packets_per_client: 4,
5586            max_packet_bytes: 512,
5587        });
5588        let mut client_transport = hub
5589            .endpoint(client_id, "127.0.0.1:23007".parse().expect("client addr"))
5590            .expect("client endpoint should register");
5591        let mut server_transport = hub
5592            .endpoint(server_id, "127.0.0.1:23000".parse().expect("server addr"))
5593            .expect("server endpoint should register");
5594        let frame = ReplicationFrame {
5595            client_id,
5596            server_tick: Tick::new(12),
5597            entity_count: 1,
5598            estimated_payload_bytes: 4,
5599            entities: vec![EntityDelta {
5600                entity_id: EntityId::new(100),
5601                owner_epoch: OwnerEpoch::new(1),
5602                components: vec![ComponentDelta {
5603                    component_id: ComponentId::new(1),
5604                    version: 1,
5605                    flags: 0,
5606                    bytes: 100_u32.to_le_bytes().to_vec(),
5607                }],
5608            }],
5609        };
5610        let mut bytes = Vec::new();
5611        BinaryFrameEncoder
5612            .encode_replication(&frame, &mut bytes)
5613            .expect("replication should encode");
5614        server_transport
5615            .send(OutboundPacket {
5616                client_id,
5617                bytes: bytes.clone(),
5618            })
5619            .expect("replication packet should send");
5620
5621        let mut receive = ReplicationReceiveBridge::new(
5622            ReplicationReceiveConfig::new(client_id).with_expected_source(server_id),
5623        );
5624        let pump = receive
5625            .pump_owned(&mut client_transport, 4)
5626            .expect("replication packet should receive");
5627
5628        assert_eq!(pump.frames_received(), 1);
5629        assert_eq!(pump.entities_received(), 1);
5630        assert_eq!(pump.components_received(), 1);
5631        assert_eq!(pump.frames[0].client_id, client_id);
5632        assert_eq!(receive.stats().packets_received, 1);
5633        assert_eq!(receive.stats().frames_received, 1);
5634        assert_eq!(receive.stats().entities_received, 1);
5635        assert_eq!(receive.stats().components_received, 1);
5636        assert!(receive.stats().bytes_received > 0);
5637
5638        server_transport
5639            .send(OutboundPacket {
5640                client_id,
5641                bytes: bytes.clone(),
5642            })
5643            .expect("visitor packet should send");
5644        let mut visited_payload = 0_u32;
5645        let visit = receive
5646            .pump(&mut client_transport, 4, |borrowed| {
5647                assert_eq!(borrowed.client_id, client_id);
5648                for entity in borrowed.entities() {
5649                    assert_eq!(entity.entity_id, EntityId::new(100));
5650                    for component in entity.components() {
5651                        visited_payload = u32::from_le_bytes(
5652                            component
5653                                .bytes
5654                                .try_into()
5655                                .expect("health payload should be four bytes"),
5656                        );
5657                    }
5658                }
5659                Ok::<_, core::convert::Infallible>(())
5660            })
5661            .expect("borrowed replication packet should visit");
5662        assert_eq!(visited_payload, 100);
5663        assert_eq!(visit.packets_received, 1);
5664        assert_eq!(visit.frames_received, 1);
5665        assert_eq!(visit.entities_received, 1);
5666        assert_eq!(visit.components_received, 1);
5667
5668        server_transport
5669            .send(OutboundPacket { client_id, bytes })
5670            .expect("visitor failure packet should send");
5671        let error = receive
5672            .pump(&mut client_transport, 4, |_| Err("apply failed"))
5673            .expect_err("visitor failure should surface separately");
5674        assert!(matches!(
5675            error,
5676            ReplicationReceiveVisitError::Visitor("apply failed")
5677        ));
5678        assert_eq!(receive.stats().packets_received, 3);
5679        assert_eq!(receive.stats().frames_received, 3);
5680        assert_eq!(receive.stats().entities_received, 3);
5681        assert_eq!(receive.stats().components_received, 3);
5682    }
5683
5684    #[test]
5685    fn replication_receive_bridge_rejects_wrong_target() {
5686        let client_id = ClientId::new(7);
5687        let server_id = ClientId::new(0);
5688        let wrong_client_id = ClientId::new(99);
5689        let hub = InMemoryTransportHub::new(ClientTransportLimits {
5690            max_queued_packets_per_client: 4,
5691            max_packet_bytes: 512,
5692        });
5693        let mut client_transport = hub
5694            .endpoint(client_id, "127.0.0.1:23107".parse().expect("client addr"))
5695            .expect("client endpoint should register");
5696        let mut server_transport = hub
5697            .endpoint(server_id, "127.0.0.1:23100".parse().expect("server addr"))
5698            .expect("server endpoint should register");
5699        let frame = ReplicationFrame {
5700            client_id: wrong_client_id,
5701            server_tick: Tick::new(12),
5702            entity_count: 1,
5703            estimated_payload_bytes: 4,
5704            entities: vec![EntityDelta {
5705                entity_id: EntityId::new(100),
5706                owner_epoch: OwnerEpoch::new(1),
5707                components: vec![ComponentDelta {
5708                    component_id: ComponentId::new(1),
5709                    version: 1,
5710                    flags: 0,
5711                    bytes: 100_u32.to_le_bytes().to_vec(),
5712                }],
5713            }],
5714        };
5715        let mut bytes = Vec::new();
5716        BinaryFrameEncoder
5717            .encode_replication(&frame, &mut bytes)
5718            .expect("replication should encode");
5719        server_transport
5720            .send(OutboundPacket { client_id, bytes })
5721            .expect("replication packet should send");
5722
5723        let mut receive = ReplicationReceiveBridge::new(
5724            ReplicationReceiveConfig::new(client_id).with_expected_source(server_id),
5725        );
5726        let error = receive
5727            .pump_owned(&mut client_transport, 4)
5728            .expect_err("wrong target should be rejected");
5729
5730        assert!(matches!(
5731            error,
5732            ReplicationReceiveError::TargetMismatch {
5733                expected,
5734                actual,
5735            } if expected == client_id && actual == wrong_client_id
5736        ));
5737        assert_eq!(receive.stats().packets_received, 1);
5738        assert_eq!(receive.stats().frames_received, 0);
5739        assert_eq!(receive.stats().frames_rejected_target, 1);
5740    }
5741
5742    #[test]
5743    #[allow(clippy::too_many_lines)]
5744    fn client_transport_bridge_sends_command_and_receives_client_frames() {
5745        let client_id = ClientId::new(7);
5746        let server_id = ClientId::new(0);
5747        let hub = InMemoryTransportHub::new(ClientTransportLimits {
5748            max_queued_packets_per_client: 8,
5749            max_packet_bytes: 512,
5750        });
5751        let mut client_transport = hub
5752            .endpoint(client_id, "127.0.0.1:23207".parse().expect("client addr"))
5753            .expect("client endpoint should register");
5754        let mut server_transport = hub
5755            .endpoint(server_id, "127.0.0.1:23200".parse().expect("server addr"))
5756            .expect("server endpoint should register");
5757        let mut bridge = ClientTransportBridge::new(
5758            ClientTransportConfig::new(client_id, server_id).with_expected_source(server_id),
5759        );
5760        let command = CommandFrame {
5761            client_id,
5762            command_id: CommandId::new(42),
5763            entity_id: EntityId::new(100),
5764            sequence: 9,
5765            kind: 1,
5766            priority: CommandPriority::High,
5767            payload: b"move:north".to_vec(),
5768        };
5769
5770        let send = bridge
5771            .send_command_frame(&mut client_transport, &command)
5772            .expect("command should send");
5773        assert_eq!(send.command_id, command.command_id);
5774        assert!(send.bytes_sent > 0);
5775        assert_eq!(bridge.stats().commands_sent, 1);
5776        assert_eq!(bridge.stats().command_bytes_sent, send.bytes_sent);
5777        let inbound = server_transport
5778            .try_recv()
5779            .expect("server receive should work")
5780            .expect("command packet should arrive");
5781        assert_eq!(inbound.client_id, Some(client_id));
5782        let RuntimeFrame::Command(decoded) = BinaryFrameDecoder
5783            .decode(&inbound.bytes)
5784            .expect("command should decode")
5785        else {
5786            panic!("expected command frame");
5787        };
5788        assert_eq!(decoded, command);
5789
5790        let ack = CommandAckFrame {
5791            client_id,
5792            command_id: command.command_id,
5793            server_tick: Tick::new(12),
5794            accepted: true,
5795            reason_code: GATEWAY_COMMAND_ACK_ACCEPTED,
5796        };
5797        let mut ack_bytes = Vec::new();
5798        BinaryFrameEncoder
5799            .encode_command_ack(&ack, &mut ack_bytes)
5800            .expect("ACK should encode");
5801        server_transport
5802            .send(OutboundPacket {
5803                client_id,
5804                bytes: ack_bytes.clone(),
5805            })
5806            .expect("ACK should send");
5807
5808        let replication = ReplicationFrame {
5809            client_id,
5810            server_tick: Tick::new(12),
5811            entity_count: 1,
5812            estimated_payload_bytes: 4,
5813            entities: vec![EntityDelta {
5814                entity_id: EntityId::new(100),
5815                owner_epoch: OwnerEpoch::new(1),
5816                components: vec![ComponentDelta {
5817                    component_id: ComponentId::new(1),
5818                    version: 1,
5819                    flags: 0,
5820                    bytes: 100_u32.to_le_bytes().to_vec(),
5821                }],
5822            }],
5823        };
5824        let mut replication_bytes = Vec::new();
5825        BinaryFrameEncoder
5826            .encode_replication(&replication, &mut replication_bytes)
5827            .expect("replication should encode");
5828        server_transport
5829            .send(OutboundPacket {
5830                client_id,
5831                bytes: replication_bytes.clone(),
5832            })
5833            .expect("replication should send");
5834
5835        let barrier = BarrierFrame {
5836            client_id,
5837            barrier_id: BarrierId::new(5),
5838            server_tick: Tick::new(12),
5839            state: BarrierState::Frozen,
5840        };
5841        let mut barrier_bytes = Vec::new();
5842        BinaryFrameEncoder
5843            .encode_barrier(&barrier, &mut barrier_bytes)
5844            .expect("barrier should encode");
5845        server_transport
5846            .send(OutboundPacket {
5847                client_id,
5848                bytes: barrier_bytes.clone(),
5849            })
5850            .expect("barrier should send");
5851
5852        let pump = bridge
5853            .pump_owned(&mut client_transport, 8)
5854            .expect("client frames should receive");
5855
5856        assert_eq!(pump.packets_received, 3);
5857        assert_eq!(pump.command_acks_received(), 1);
5858        assert_eq!(pump.replication_frames_received(), 1);
5859        assert_eq!(pump.barrier_frames_received(), 1);
5860        assert_eq!(pump.entities_received(), 1);
5861        assert_eq!(pump.components_received(), 1);
5862        assert_eq!(pump.command_acks[0], ack);
5863        assert_eq!(pump.replication_frames[0], replication);
5864        assert_eq!(pump.barriers[0], barrier);
5865        assert_eq!(bridge.stats().packets_received, 3);
5866        assert_eq!(bridge.stats().command_acks_received, 1);
5867        assert_eq!(bridge.stats().replication_frames_received, 1);
5868        assert_eq!(bridge.stats().barrier_frames_received, 1);
5869        assert_eq!(bridge.stats().entities_received, 1);
5870        assert_eq!(bridge.stats().components_received, 1);
5871
5872        for bytes in [ack_bytes.clone(), replication_bytes, barrier_bytes] {
5873            server_transport
5874                .send(OutboundPacket { client_id, bytes })
5875                .expect("visitor packet should send");
5876        }
5877        let mut visited_ack = 0_usize;
5878        let mut visited_replication = 0_usize;
5879        let mut visited_barrier = 0_usize;
5880        let mut payload_checksum = 0_u64;
5881        let visit = bridge
5882            .pump(&mut client_transport, 8, |frame| {
5883                match frame {
5884                    ClientInboundFrameRef::CommandAck(frame) => {
5885                        assert_eq!(frame, ack);
5886                        visited_ack = visited_ack.saturating_add(1);
5887                    }
5888                    ClientInboundFrameRef::Replication(frame) => {
5889                        assert_eq!(frame.client_id, client_id);
5890                        assert_eq!(frame.encoded_entity_count(), 1);
5891                        for entity in frame.entities() {
5892                            for component in entity.components() {
5893                                payload_checksum = payload_checksum.saturating_add(
5894                                    component.bytes.iter().map(|byte| u64::from(*byte)).sum(),
5895                                );
5896                            }
5897                        }
5898                        visited_replication = visited_replication.saturating_add(1);
5899                    }
5900                    ClientInboundFrameRef::Barrier(frame) => {
5901                        assert_eq!(frame, barrier);
5902                        visited_barrier = visited_barrier.saturating_add(1);
5903                    }
5904                }
5905                Ok::<(), &'static str>(())
5906            })
5907            .expect("mixed visitor pump should work");
5908        assert_eq!(visit.packets_received, 3);
5909        assert_eq!(visit.command_acks_received, 1);
5910        assert_eq!(visit.replication_frames_received, 1);
5911        assert_eq!(visit.barrier_frames_received, 1);
5912        assert_eq!(visit.entities_received, 1);
5913        assert_eq!(visit.components_received, 1);
5914        assert_eq!(
5915            (visited_ack, visited_replication, visited_barrier),
5916            (1, 1, 1)
5917        );
5918        assert_eq!(payload_checksum, 100);
5919        assert_eq!(bridge.stats().packets_received, 6);
5920
5921        server_transport
5922            .send(OutboundPacket {
5923                client_id,
5924                bytes: ack_bytes,
5925            })
5926            .expect("failing visitor packet should send");
5927        let visitor_error = bridge
5928            .pump(&mut client_transport, 1, |_| Err("apply failed"))
5929            .expect_err("visitor failure should propagate");
5930        assert_eq!(
5931            visitor_error,
5932            ClientTransportVisitError::Visitor("apply failed")
5933        );
5934        assert_eq!(bridge.stats().packets_received, 7);
5935        assert_eq!(bridge.stats().command_acks_received, 3);
5936    }
5937
5938    #[test]
5939    fn client_transport_bridge_rejects_wrong_ack_target() {
5940        let client_id = ClientId::new(7);
5941        let server_id = ClientId::new(0);
5942        let wrong_client_id = ClientId::new(99);
5943        let hub = InMemoryTransportHub::new(ClientTransportLimits {
5944            max_queued_packets_per_client: 4,
5945            max_packet_bytes: 512,
5946        });
5947        let mut client_transport = hub
5948            .endpoint(client_id, "127.0.0.1:23307".parse().expect("client addr"))
5949            .expect("client endpoint should register");
5950        let mut server_transport = hub
5951            .endpoint(server_id, "127.0.0.1:23300".parse().expect("server addr"))
5952            .expect("server endpoint should register");
5953        let mut bridge = ClientTransportBridge::new(
5954            ClientTransportConfig::new(client_id, server_id).with_expected_source(server_id),
5955        );
5956        let ack = CommandAckFrame {
5957            client_id: wrong_client_id,
5958            command_id: CommandId::new(42),
5959            server_tick: Tick::new(12),
5960            accepted: true,
5961            reason_code: GATEWAY_COMMAND_ACK_ACCEPTED,
5962        };
5963        let mut ack_bytes = Vec::new();
5964        BinaryFrameEncoder
5965            .encode_command_ack(&ack, &mut ack_bytes)
5966            .expect("ACK should encode");
5967        server_transport
5968            .send(OutboundPacket {
5969                client_id,
5970                bytes: ack_bytes,
5971            })
5972            .expect("ACK should send");
5973
5974        let error = bridge
5975            .pump_owned(&mut client_transport, 4)
5976            .expect_err("wrong target should be rejected");
5977
5978        assert!(matches!(
5979            error,
5980            ClientTransportBridgeError::TargetMismatch {
5981                kind: ClientInboundFrameKind::CommandAck,
5982                expected,
5983                actual,
5984            } if expected == client_id && actual == wrong_client_id
5985        ));
5986        assert_eq!(bridge.stats().packets_received, 1);
5987        assert_eq!(bridge.stats().command_acks_received, 0);
5988        assert_eq!(bridge.stats().frames_rejected_target, 1);
5989    }
5990
5991    #[test]
5992    #[allow(clippy::too_many_lines)]
5993    fn gateway_client_transport_bridge_queues_command_and_sends_ack() {
5994        let client_id = ClientId::new(7);
5995        let server_id = ClientId::new(0);
5996        let station_id = StationId::new(1);
5997        let hub = InMemoryTransportHub::new(ClientTransportLimits {
5998            max_queued_packets_per_client: 8,
5999            max_packet_bytes: 512,
6000        });
6001        let mut client_transport = hub
6002            .endpoint(client_id, "127.0.0.1:23507".parse().expect("client addr"))
6003            .expect("client endpoint should register");
6004        let mut server_transport = hub
6005            .endpoint(server_id, "127.0.0.1:23500".parse().expect("server addr"))
6006            .expect("server endpoint should register");
6007        let mut client_bridge = ClientTransportBridge::new(
6008            ClientTransportConfig::new(client_id, server_id).with_expected_source(server_id),
6009        );
6010        let command = CommandFrame {
6011            client_id,
6012            command_id: CommandId::new(42),
6013            entity_id: EntityId::new(100),
6014            sequence: 9,
6015            kind: 1,
6016            priority: CommandPriority::High,
6017            payload: b"move:north".to_vec(),
6018        };
6019        client_bridge
6020            .send_command_frame(&mut client_transport, &command)
6021            .expect("client command should send");
6022
6023        let mut gateway = gateway(4);
6024        gateway
6025            .connect(client_id, station_id, Tick::new(10))
6026            .expect("client should connect");
6027        let mut station_queues = BTreeMap::from([(station_id, command_queues())]);
6028        let mut pipeline = GatewayCommandPipeline::default();
6029        let mut gateway_bridge = GatewayClientTransportBridge::default();
6030
6031        let pump = gateway_bridge
6032            .pump_ingress(
6033                &mut server_transport,
6034                &mut pipeline,
6035                &mut gateway,
6036                &mut station_queues,
6037                Tick::new(10),
6038                CommandIngress::RUNNING,
6039                4,
6040            )
6041            .expect("gateway client transport should pump");
6042
6043        assert_eq!(pump.packets_received, 1);
6044        assert_eq!(pump.commands_processed(), 1);
6045        assert_eq!(pump.commands_accepted(), 1);
6046        assert_eq!(pump.acks_sent, 1);
6047        assert_eq!(gateway_bridge.stats().packets_received, 1);
6048        assert_eq!(gateway_bridge.stats().command_frames_received, 1);
6049        assert_eq!(gateway_bridge.stats().commands_accepted, 1);
6050        assert_eq!(gateway_bridge.stats().acks_sent, 1);
6051        let queued = station_queues
6052            .get_mut(&station_id)
6053            .expect("station queue should exist")
6054            .pop_next()
6055            .expect("command should queue");
6056        assert_eq!(queued.id, command.command_id);
6057
6058        let ack_pump = client_bridge
6059            .pump_owned(&mut client_transport, 4)
6060            .expect("client should receive ACK");
6061        assert_eq!(ack_pump.command_acks_received(), 1);
6062        assert!(ack_pump.command_acks[0].accepted);
6063        assert_eq!(ack_pump.command_acks[0].command_id, command.command_id);
6064
6065        let compact_command = CommandFrame {
6066            command_id: CommandId::new(43),
6067            sequence: 10,
6068            ..command
6069        };
6070        client_bridge
6071            .send_command_frame(&mut client_transport, &compact_command)
6072            .expect("second client command should send");
6073        let summary = gateway_bridge
6074            .pump_ingress_compact(
6075                &mut server_transport,
6076                &mut pipeline,
6077                &mut gateway,
6078                &mut station_queues,
6079                Tick::new(11),
6080                CommandIngress::RUNNING,
6081                4,
6082            )
6083            .expect("compact gateway transport should pump");
6084        assert_eq!(summary.packets_received, 1);
6085        assert_eq!(summary.commands_accepted, 1);
6086        assert_eq!(summary.commands_rejected, 0);
6087        assert_eq!(summary.acks_sent, 1);
6088        assert!(summary.ack_bytes_sent > 0);
6089        let compact_queued = station_queues
6090            .get_mut(&station_id)
6091            .expect("station queue should exist")
6092            .pop_next()
6093            .expect("compact command should queue");
6094        assert_eq!(compact_queued.id, compact_command.command_id);
6095        let compact_ack = client_bridge
6096            .pump_owned(&mut client_transport, 4)
6097            .expect("client should receive compact ACK");
6098        assert_eq!(compact_ack.command_acks_received(), 1);
6099        assert_eq!(
6100            compact_ack.command_acks[0].command_id,
6101            compact_command.command_id
6102        );
6103        assert_eq!(gateway_bridge.stats().packets_received, 2);
6104        assert_eq!(gateway_bridge.stats().commands_accepted, 2);
6105        assert_eq!(gateway_bridge.stats().acks_sent, 2);
6106    }
6107
6108    #[test]
6109    fn gateway_client_transport_bridge_rejects_source_mismatch_before_admission() {
6110        let packet_client_id = ClientId::new(7);
6111        let frame_client_id = ClientId::new(8);
6112        let server_id = ClientId::new(0);
6113        let station_id = StationId::new(1);
6114        let hub = InMemoryTransportHub::new(ClientTransportLimits {
6115            max_queued_packets_per_client: 4,
6116            max_packet_bytes: 512,
6117        });
6118        let mut packet_client_transport = hub
6119            .endpoint(
6120                packet_client_id,
6121                "127.0.0.1:23607".parse().expect("client addr"),
6122            )
6123            .expect("client endpoint should register");
6124        let mut server_transport = hub
6125            .endpoint(server_id, "127.0.0.1:23600".parse().expect("server addr"))
6126            .expect("server endpoint should register");
6127        let command = CommandFrame {
6128            client_id: frame_client_id,
6129            command_id: CommandId::new(42),
6130            entity_id: EntityId::new(100),
6131            sequence: 9,
6132            kind: 1,
6133            priority: CommandPriority::High,
6134            payload: b"move:north".to_vec(),
6135        };
6136        let mut bytes = Vec::new();
6137        BinaryFrameEncoder
6138            .encode_command(&command, &mut bytes)
6139            .expect("command should encode");
6140        packet_client_transport
6141            .send(OutboundPacket {
6142                client_id: server_id,
6143                bytes,
6144            })
6145            .expect("packet should send");
6146
6147        let mut gateway = gateway(4);
6148        gateway
6149            .connect(frame_client_id, station_id, Tick::new(10))
6150            .expect("frame client should connect");
6151        let mut station_queues = BTreeMap::from([(station_id, command_queues())]);
6152        let mut pipeline = GatewayCommandPipeline::default();
6153        let mut gateway_bridge = GatewayClientTransportBridge::default();
6154
6155        let error = gateway_bridge
6156            .pump_ingress(
6157                &mut server_transport,
6158                &mut pipeline,
6159                &mut gateway,
6160                &mut station_queues,
6161                Tick::new(10),
6162                CommandIngress::RUNNING,
6163                4,
6164            )
6165            .expect_err("source mismatch should reject before admission");
6166
6167        assert!(matches!(
6168            error,
6169            GatewayClientTransportError::SourceMismatch {
6170                packet_client_id: actual_packet,
6171                frame_client_id: actual_frame,
6172            } if actual_packet == packet_client_id && actual_frame == frame_client_id
6173        ));
6174        assert_eq!(gateway_bridge.stats().source_mismatches, 1);
6175        assert_eq!(gateway_bridge.stats().commands_accepted, 0);
6176        assert_eq!(pipeline.stats().commands_admitted, 0);
6177        assert_eq!(
6178            station_queues
6179                .get(&station_id)
6180                .expect("station queue should exist")
6181                .total_len(),
6182            0
6183        );
6184    }
6185
6186    #[test]
6187    fn gateway_command_pipeline_queues_command_and_encodes_ack() {
6188        let client_id = ClientId::new(7);
6189        let station_id = StationId::new(1);
6190        let mut gateway = gateway(4);
6191        gateway
6192            .connect(client_id, station_id, Tick::new(10))
6193            .expect("client should connect");
6194        let mut station_queues = BTreeMap::from([(station_id, command_queues())]);
6195        let mut pipeline = GatewayCommandPipeline::default();
6196
6197        let report = pipeline.process(
6198            &mut gateway,
6199            &mut station_queues,
6200            &encode_command_frame(1),
6201            Tick::new(10),
6202            CommandIngress::RUNNING,
6203        );
6204
6205        assert!(report.accepted);
6206        assert_eq!(report.reason_code, GATEWAY_COMMAND_ACK_ACCEPTED);
6207        assert_eq!(report.station_id, Some(station_id));
6208        assert!(report.error.is_none());
6209        let ack_bytes = report.ack_bytes.expect("ACK should encode");
6210        let RuntimeFrame::CommandAck(ack) = BinaryFrameDecoder
6211            .decode(&ack_bytes)
6212            .expect("ACK should decode")
6213        else {
6214            panic!("expected command ACK");
6215        };
6216        assert!(ack.accepted);
6217        assert_eq!(ack.command_id, CommandId::new(1));
6218        let queued = station_queues
6219            .get_mut(&station_id)
6220            .expect("queue should exist")
6221            .pop_next()
6222            .expect("command should queue");
6223        assert_eq!(queued.id, CommandId::new(1));
6224        assert_eq!(pipeline.stats().commands_admitted, 1);
6225        assert_eq!(pipeline.stats().commands_enqueued, 1);
6226        assert_eq!(pipeline.stats().acks_encoded, 1);
6227    }
6228
6229    #[test]
6230    fn gateway_command_pipeline_negative_acks_rate_limit() {
6231        let client_id = ClientId::new(7);
6232        let station_id = StationId::new(1);
6233        let mut gateway = gateway(1);
6234        gateway
6235            .connect(client_id, station_id, Tick::new(10))
6236            .expect("client should connect");
6237        let mut station_queues = BTreeMap::from([(station_id, command_queues())]);
6238        let mut pipeline = GatewayCommandPipeline::default();
6239
6240        assert!(
6241            pipeline
6242                .process(
6243                    &mut gateway,
6244                    &mut station_queues,
6245                    &encode_command_frame(1),
6246                    Tick::new(10),
6247                    CommandIngress::RUNNING,
6248                )
6249                .accepted
6250        );
6251        let rejected = pipeline.process(
6252            &mut gateway,
6253            &mut station_queues,
6254            &encode_command_frame(2),
6255            Tick::new(10),
6256            CommandIngress::RUNNING,
6257        );
6258
6259        assert!(!rejected.accepted);
6260        assert_eq!(rejected.reason_code, GATEWAY_COMMAND_ACK_RATE_LIMITED);
6261        assert!(matches!(
6262            rejected.error,
6263            Some(GatewayCommandPipelineError::Gateway(
6264                GatewayError::RateLimited { .. }
6265            ))
6266        ));
6267        let RuntimeFrame::CommandAck(ack) = BinaryFrameDecoder
6268            .decode(&rejected.ack_bytes.expect("rejection ACK should encode"))
6269            .expect("ACK should decode")
6270        else {
6271            panic!("expected command ACK");
6272        };
6273        assert!(!ack.accepted);
6274        assert_eq!(ack.reason_code, GATEWAY_COMMAND_ACK_RATE_LIMITED);
6275        assert_eq!(pipeline.stats().commands_rejected_gateway, 1);
6276    }
6277
6278    #[test]
6279    fn gateway_command_pipeline_rejects_missing_station_queue() {
6280        let client_id = ClientId::new(7);
6281        let station_id = StationId::new(1);
6282        let mut gateway = gateway(4);
6283        gateway
6284            .connect(client_id, station_id, Tick::new(10))
6285            .expect("client should connect");
6286        let mut station_queues = BTreeMap::new();
6287        let mut pipeline = GatewayCommandPipeline::default();
6288
6289        let report = pipeline.process(
6290            &mut gateway,
6291            &mut station_queues,
6292            &encode_command_frame(1),
6293            Tick::new(10),
6294            CommandIngress::RUNNING,
6295        );
6296
6297        assert!(!report.accepted);
6298        assert_eq!(report.station_id, Some(station_id));
6299        assert_eq!(report.reason_code, GATEWAY_COMMAND_ACK_MISSING_QUEUE);
6300        assert!(matches!(
6301            report.error,
6302            Some(GatewayCommandPipelineError::MissingQueue(id)) if id == station_id
6303        ));
6304        assert_eq!(pipeline.stats().commands_admitted, 1);
6305        assert_eq!(pipeline.stats().commands_rejected_queue, 1);
6306    }
6307
6308    #[test]
6309    fn gateway_command_pipeline_dispatches_to_deployment_route() {
6310        let client_id = ClientId::new(7);
6311        let station_id = StationId::new(1);
6312        let node_id = NodeId::new(9);
6313        let mut gateway = gateway(4);
6314        gateway
6315            .connect(client_id, station_id, Tick::new(10))
6316            .expect("client should connect");
6317        let mut deployment = DeploymentRouteTable::new(DeploymentConfig {
6318            max_nodes: 4,
6319            max_stations_per_node: 4,
6320            stale_after_ticks: 10,
6321        });
6322        deployment
6323            .register_node(node_id, 4, Tick::new(10))
6324            .expect("node should register");
6325        deployment
6326            .assign_station(station_id, node_id, Tick::new(10))
6327            .expect("station should assign");
6328        let mut pipeline = GatewayCommandPipeline::default();
6329
6330        let report = pipeline.dispatch(
6331            &mut gateway,
6332            &deployment,
6333            &encode_command_frame(1),
6334            Tick::new(12),
6335        );
6336
6337        assert!(report.accepted);
6338        assert_eq!(report.station_id, Some(station_id));
6339        assert_eq!(report.node_id, Some(node_id));
6340        let delivery = report.delivery.expect("delivery should resolve");
6341        assert_eq!(delivery.client_id, client_id);
6342        assert_eq!(delivery.station_id, station_id);
6343        assert_eq!(delivery.node_id, node_id);
6344        assert_eq!(delivery.station_route_epoch, 1);
6345        assert_eq!(
6346            report
6347                .command
6348                .expect("command should be returned")
6349                .received_at,
6350            Tick::new(12)
6351        );
6352        let RuntimeFrame::CommandAck(ack) = BinaryFrameDecoder
6353            .decode(&report.ack_bytes.expect("ACK should encode"))
6354            .expect("ACK should decode")
6355        else {
6356            panic!("expected command ACK");
6357        };
6358        assert!(ack.accepted);
6359        assert_eq!(pipeline.stats().commands_routed_deployment, 1);
6360    }
6361
6362    #[test]
6363    fn gateway_command_pipeline_negative_acks_missing_deployment_route() {
6364        let client_id = ClientId::new(7);
6365        let station_id = StationId::new(1);
6366        let mut gateway = gateway(4);
6367        gateway
6368            .connect(client_id, station_id, Tick::new(10))
6369            .expect("client should connect");
6370        let deployment = DeploymentRouteTable::default();
6371        let mut pipeline = GatewayCommandPipeline::default();
6372
6373        let report = pipeline.dispatch(
6374            &mut gateway,
6375            &deployment,
6376            &encode_command_frame(1),
6377            Tick::new(12),
6378        );
6379
6380        assert!(!report.accepted);
6381        assert_eq!(report.station_id, Some(station_id));
6382        assert_eq!(report.reason_code, GATEWAY_COMMAND_ACK_DEPLOYMENT_REJECTED);
6383        assert!(matches!(
6384            report.error,
6385            Some(GatewayCommandPipelineError::Deployment(
6386                DeploymentError::MissingStation(id)
6387            )) if id == station_id
6388        ));
6389        let RuntimeFrame::CommandAck(ack) = BinaryFrameDecoder
6390            .decode(&report.ack_bytes.expect("rejection ACK should encode"))
6391            .expect("ACK should decode")
6392        else {
6393            panic!("expected command ACK");
6394        };
6395        assert!(!ack.accepted);
6396        assert_eq!(ack.reason_code, GATEWAY_COMMAND_ACK_DEPLOYMENT_REJECTED);
6397        assert_eq!(pipeline.stats().commands_rejected_deployment, 1);
6398    }
6399
6400    #[test]
6401    fn gateway_command_pipeline_rejects_non_command_frame() {
6402        let ack = CommandAckFrame {
6403            client_id: ClientId::new(7),
6404            command_id: CommandId::new(1),
6405            server_tick: Tick::new(10),
6406            accepted: true,
6407            reason_code: 0,
6408        };
6409        let mut bytes = Vec::new();
6410        BinaryFrameEncoder
6411            .encode_command_ack(&ack, &mut bytes)
6412            .expect("ACK should encode");
6413        let mut gateway = gateway(4);
6414        let mut station_queues = BTreeMap::new();
6415        let mut pipeline = GatewayCommandPipeline::default();
6416
6417        let report = pipeline.process(
6418            &mut gateway,
6419            &mut station_queues,
6420            &bytes,
6421            Tick::new(10),
6422            CommandIngress::RUNNING,
6423        );
6424
6425        assert!(!report.accepted);
6426        assert!(report.ack_bytes.is_none());
6427        assert_eq!(
6428            report.error,
6429            Some(GatewayCommandPipelineError::NonCommandFrame)
6430        );
6431        assert_eq!(pipeline.stats().frames_rejected_non_command, 1);
6432    }
6433
6434    #[test]
6435    fn migration_executor_moves_owner_and_leaves_source_ghost() {
6436        let mut stations = StationSet::default();
6437        let mut source = station(1, 10);
6438        source
6439            .spawn_owned(
6440                EntityId::new(99),
6441                Position3::new(1.0, 2.0, 3.0),
6442                Bounds::Point,
6443                PolicyId::new(0),
6444            )
6445            .expect("spawn should work");
6446        stations.push(source);
6447        stations.push(station(2, 10));
6448
6449        let report = EntityMigrationExecutor::migrate_entity(
6450            &mut stations,
6451            EntityId::new(99),
6452            StationId::new(1),
6453            StationId::new(2),
6454            4,
6455        )
6456        .expect("migration should work");
6457
6458        assert_eq!(report.transfer.target_station, StationId::new(2));
6459        assert!(
6460            !stations
6461                .get(StationId::new(1))
6462                .expect("source")
6463                .get_by_id(EntityId::new(99))
6464                .expect("source ghost")
6465                .is_owned()
6466        );
6467        assert!(
6468            stations
6469                .get(StationId::new(2))
6470                .expect("target")
6471                .get_by_id(EntityId::new(99))
6472                .expect("target owner")
6473                .is_owned()
6474        );
6475    }
6476
6477    #[test]
6478    fn event_router_delays_until_target_tick_and_scheduler_drains() {
6479        let mut stations = StationSet::default();
6480        stations.push(station(1, 10));
6481        stations.push(station(2, 10));
6482
6483        let mut router = EventRouter::default();
6484        router.register_stations(&stations);
6485        router
6486            .route(StationEvent {
6487                id: EventId::new(1),
6488                source: StationId::new(1),
6489                target: StationId::new(2),
6490                source_tick: Tick::new(0),
6491                target_tick: Tick::new(2),
6492                priority: EventPriority::Critical,
6493                kind: EventKind::Custom(7),
6494            })
6495            .expect("route should work");
6496
6497        let mut scheduler = StationScheduler::default();
6498        let mut drained = Vec::new();
6499        scheduler.advance_all(&mut stations);
6500        scheduler
6501            .drain_ready_events_into(&stations, &mut router, &mut drained)
6502            .expect("drain should work");
6503        assert!(drained.is_empty());
6504
6505        scheduler.advance_all(&mut stations);
6506        scheduler
6507            .drain_ready_events_into(&stations, &mut router, &mut drained)
6508            .expect("drain should work");
6509        assert_eq!(drained.len(), 1);
6510        let retained_capacity = drained.capacity();
6511
6512        scheduler
6513            .drain_ready_events_into(&stations, &mut router, &mut drained)
6514            .expect("empty drain should work");
6515        assert!(drained.is_empty());
6516        assert_eq!(drained.capacity(), retained_capacity);
6517        assert_eq!(router.stats().routed_events, 1);
6518        assert_eq!(router.stats().drained_events, 1);
6519    }
6520
6521    #[test]
6522    fn event_router_unregisters_station_and_discards_queued_events() {
6523        let station_id = StationId::new(2);
6524        let mut router = EventRouter::default();
6525        router.register_station(station_id);
6526        router
6527            .route(StationEvent {
6528                id: EventId::new(1),
6529                source: StationId::new(1),
6530                target: station_id,
6531                source_tick: Tick::new(0),
6532                target_tick: Tick::new(10),
6533                priority: EventPriority::Important,
6534                kind: EventKind::Custom(1),
6535            })
6536            .expect("event should queue");
6537
6538        assert_eq!(router.unregister_station(station_id), Some(1));
6539        assert_eq!(router.unregister_station(station_id), None);
6540        assert_eq!(router.queued_len(station_id), None);
6541        assert_eq!(
6542            router.drain_ready(station_id, Tick::new(10)),
6543            Err(EventRouterError::MissingTarget(station_id))
6544        );
6545    }
6546
6547    #[test]
6548    fn station_scheduler_prioritizes_loaded_stations_with_budget() {
6549        let mut stations = StationSet::default();
6550        stations.push(station(1, 10));
6551        stations.push(station(2, 10));
6552        stations.push(station(3, 10));
6553
6554        let samples = vec![
6555            StationLoadSample {
6556                station_id: StationId::new(1),
6557                owned_entities: 1,
6558                ..StationLoadSample::default()
6559            },
6560            StationLoadSample {
6561                station_id: StationId::new(2),
6562                owned_entities: 100,
6563                subscribers: 40,
6564                queued_events: 20,
6565                tick_cost_units: 500,
6566                cells: vec![CellLoadSample {
6567                    cell: CellCoord3::new(0, 0, 0),
6568                    owned_entities: 90,
6569                    subscribers: 40,
6570                    event_pressure: 10,
6571                    ..CellLoadSample::default()
6572                }],
6573                ..StationLoadSample::default()
6574            },
6575            StationLoadSample {
6576                station_id: StationId::new(3),
6577                owned_entities: 25,
6578                subscribers: 10,
6579                queued_events: 5,
6580                tick_cost_units: 50,
6581                ..StationLoadSample::default()
6582            },
6583        ];
6584
6585        let mut scheduler = StationScheduler::default();
6586        let mut schedule_scratch = StationScheduleScratch::new();
6587        let plan = scheduler.advance_loaded_into(
6588            &mut stations,
6589            &samples,
6590            StationScheduleConfig {
6591                max_station_advances_per_step: 2,
6592            },
6593            &mut schedule_scratch,
6594        );
6595
6596        assert_eq!(plan.candidates_considered, 3);
6597        assert_eq!(plan.stations_selected, 2);
6598        assert_eq!(plan.total_advances, 2);
6599        assert_eq!(
6600            plan.selected
6601                .iter()
6602                .map(|candidate| candidate.station_id)
6603                .collect::<Vec<_>>(),
6604            vec![StationId::new(2), StationId::new(3)]
6605        );
6606        assert_eq!(scheduler.advanced_ticks, 2);
6607        assert_eq!(
6608            stations.get(StationId::new(1)).expect("station").tick(),
6609            Tick::new(0)
6610        );
6611        assert_eq!(
6612            stations.get(StationId::new(2)).expect("station").tick(),
6613            Tick::new(1)
6614        );
6615        assert_eq!(
6616            stations.get(StationId::new(3)).expect("station").tick(),
6617            Tick::new(1)
6618        );
6619    }
6620
6621    #[test]
6622    fn station_scheduler_top_k_matches_full_sort_for_budget_edges() {
6623        let candidates = (0_u32..257)
6624            .map(|index| StationScheduleCandidate {
6625                station_id: StationId::new(index),
6626                load_score: u64::from(index.wrapping_mul(37) % 23),
6627                tick_lag: u64::from(index.wrapping_mul(19) % 11),
6628            })
6629            .collect::<Vec<_>>();
6630
6631        for requested in [0, 1, 7, 64, 128, 129, 256, 257, 300] {
6632            let limit = requested.min(candidates.len());
6633            let mut expected = candidates.clone();
6634            expected.sort_by(compare_station_schedule_candidates);
6635            expected.truncate(limit);
6636            let mut actual = candidates.clone();
6637            prioritize_station_candidates(&mut actual, limit);
6638
6639            assert_eq!(&actual[..limit], expected.as_slice());
6640        }
6641    }
6642
6643    #[test]
6644    fn station_schedule_scratch_reuses_capacity_and_last_sample_wins() {
6645        let mut stations = StationSet::default();
6646        for station_id in 1..=8 {
6647            stations.push(station(station_id, 10));
6648        }
6649        let samples = [
6650            StationLoadSample {
6651                station_id: StationId::new(3),
6652                owned_entities: 1,
6653                ..StationLoadSample::default()
6654            },
6655            StationLoadSample {
6656                station_id: StationId::new(3),
6657                owned_entities: 500,
6658                ..StationLoadSample::default()
6659            },
6660        ];
6661        let scheduler = StationScheduler::default();
6662        let mut scratch = StationScheduleScratch::new();
6663
6664        {
6665            let plan = scheduler.plan_loaded_into(
6666                &stations,
6667                &samples,
6668                StationScheduleConfig {
6669                    max_station_advances_per_step: 2,
6670                },
6671                &mut scratch,
6672            );
6673            assert_eq!(plan.candidates_considered, 8);
6674            assert_eq!(plan.selected[0].station_id, StationId::new(3));
6675            assert_eq!(
6676                plan.selected[0].load_score,
6677                station_schedule_score(&samples[1])
6678            );
6679        }
6680        let score_capacity = scratch.score_capacity();
6681        let candidate_capacity = scratch.candidate_capacity();
6682
6683        let plan = scheduler.plan_loaded_into(
6684            &stations,
6685            &samples[..1],
6686            StationScheduleConfig {
6687                max_station_advances_per_step: 1,
6688            },
6689            &mut scratch,
6690        );
6691        assert_eq!(plan.selected.len(), 1);
6692        assert_eq!(scratch.score_capacity(), score_capacity);
6693        assert_eq!(scratch.candidate_capacity(), candidate_capacity);
6694    }
6695
6696    #[test]
6697    fn station_load_sampler_derives_cells_router_and_subscribers() {
6698        let station_id = StationId::new(1);
6699        let owner_position = Position3::new(1.0, 0.0, 0.0);
6700        let ghost_position = Position3::new(12.0, 0.0, 0.0);
6701        let policy_id = PolicyId::new(1);
6702        let mut station = station(1, 10);
6703        let owner = station
6704            .spawn_owned(EntityId::new(10), owner_position, Bounds::Point, policy_id)
6705            .expect("owner should spawn");
6706        let ghost = station.upsert_ghost(
6707            EntityId::new(20),
6708            ghost_position,
6709            Bounds::Point,
6710            policy_id,
6711            StationId::new(9),
6712            OwnerEpoch::new(3),
6713            Tick::new(30),
6714        );
6715
6716        let grid = GridSpec::new(10.0).expect("grid should build");
6717        let mut index = CellIndex::new(grid);
6718        index.upsert(owner, owner_position, Bounds::Point);
6719        index.upsert(ghost, ghost_position, Bounds::Point);
6720        let mut indexes = StationIndexSet::default();
6721        indexes.insert(station_id, index);
6722
6723        let mut stations = StationSet::default();
6724        stations.push(station);
6725        let mut router = EventRouter::default();
6726        router.register_station(station_id);
6727        for (event_id, kind) in [(1_u64, 1_u32), (2, 2)] {
6728            router
6729                .route(StationEvent {
6730                    id: EventId::new(event_id),
6731                    source: StationId::new(9),
6732                    target: station_id,
6733                    source_tick: Tick::new(0),
6734                    target_tick: Tick::new(4),
6735                    priority: EventPriority::Important,
6736                    kind: EventKind::Custom(kind),
6737                })
6738                .expect("event should queue");
6739        }
6740
6741        assert_eq!(indexes.iter().count(), 1);
6742        let load_sampler = StationLoadSampler::default();
6743        let mut load_scratch = StationLoadSamplerScratch::new();
6744        let samples = load_sampler.sample_all_into(
6745            &stations,
6746            &indexes,
6747            &router,
6748            &[(station_id, 2), (station_id, 3)],
6749            &mut load_scratch,
6750        );
6751
6752        assert_eq!(samples.len(), 1);
6753        let sample = &samples[0];
6754        assert_eq!(sample.station_id, station_id);
6755        assert_eq!(sample.owned_entities, 1);
6756        assert_eq!(sample.ghost_entities, 1);
6757        assert_eq!(sample.subscribers, 5);
6758        assert_eq!(sample.queued_events, 2);
6759        assert_eq!(sample.estimated_bytes, 240);
6760        assert_eq!(sample.tick_cost_units, 7);
6761        assert_eq!(
6762            sample.cells,
6763            vec![
6764                CellLoadSample {
6765                    cell: grid.cell_at(owner_position),
6766                    owned_entities: 1,
6767                    ghost_entities: 0,
6768                    subscribers: 0,
6769                    estimated_updates: 1,
6770                    estimated_bytes: 48,
6771                    tick_cost_units: 3,
6772                    event_pressure: 0,
6773                },
6774                CellLoadSample {
6775                    cell: grid.cell_at(ghost_position),
6776                    owned_entities: 0,
6777                    ghost_entities: 1,
6778                    subscribers: 0,
6779                    estimated_updates: 1,
6780                    estimated_bytes: 48,
6781                    tick_cost_units: 2,
6782                    event_pressure: 0,
6783                },
6784            ]
6785        );
6786
6787        assert_load_sampler_scratch_reuse(
6788            &load_sampler,
6789            &stations,
6790            &indexes,
6791            &router,
6792            station_id,
6793            samples,
6794        );
6795    }
6796
6797    fn assert_load_sampler_scratch_reuse(
6798        load_sampler: &StationLoadSampler,
6799        stations: &StationSet,
6800        indexes: &StationIndexSet,
6801        router: &EventRouter,
6802        station_id: StationId,
6803        samples: &[StationLoadSample],
6804    ) {
6805        let mut scratch = StationLoadSamplerScratch::new();
6806        let (sample_ptr, cell_ptr) = {
6807            let reused = load_sampler.sample_all_into(
6808                stations,
6809                indexes,
6810                router,
6811                &[(station_id, 2), (station_id, 3)],
6812                &mut scratch,
6813            );
6814            assert_eq!(reused, samples);
6815            (reused.as_ptr(), reused[0].cells.as_ptr())
6816        };
6817        let subscriber_capacity = scratch.retained_subscriber_capacity();
6818        let occupancy_capacity = scratch.retained_occupancy_capacity();
6819        let cell_capacity = scratch.retained_cell_capacity();
6820
6821        let reused = load_sampler.sample_all_into(
6822            stations,
6823            indexes,
6824            router,
6825            &[(station_id, 2), (station_id, 3)],
6826            &mut scratch,
6827        );
6828        assert_eq!(reused, samples);
6829        assert_eq!(reused.as_ptr(), sample_ptr);
6830        assert_eq!(reused[0].cells.as_ptr(), cell_ptr);
6831        assert_eq!(scratch.retained_sample_slots(), 1);
6832        assert_eq!(scratch.retained_subscriber_capacity(), subscriber_capacity);
6833        assert_eq!(scratch.retained_occupancy_capacity(), occupancy_capacity);
6834        assert_eq!(scratch.retained_cell_capacity(), cell_capacity);
6835    }
6836
6837    #[test]
6838    fn station_event_transport_bridge_routes_events_through_bounded_packets() {
6839        let mut stations = StationSet::default();
6840        stations.push(station(1, 10));
6841        stations.push(station(2, 10));
6842
6843        let mut router = EventRouter::default();
6844        router.register_stations(&stations);
6845        let mut transport = InMemoryStationTransport::default();
6846        transport.register_station(StationId::new(2));
6847        let mut bridge = StationEventTransportBridge::default();
6848        let event = StationEvent {
6849            id: EventId::new(7),
6850            source: StationId::new(1),
6851            target: StationId::new(2),
6852            source_tick: Tick::new(0),
6853            target_tick: Tick::new(1),
6854            priority: EventPriority::Important,
6855            kind: EventKind::Custom(99),
6856        };
6857
6858        bridge
6859            .send_event(&mut transport, &event)
6860            .expect("event should encode and send");
6861        assert_eq!(transport.queued_len(StationId::new(2)), Some(1));
6862
6863        let report = bridge
6864            .pump_target(&mut transport, &mut router, StationId::new(2), 4)
6865            .expect("event should pump into router");
6866        assert_eq!(report.packets_received, 1);
6867        assert_eq!(report.events_routed, 1);
6868        assert_eq!(router.queued_len(StationId::new(2)), Some(1));
6869
6870        let mut scheduler = StationScheduler::default();
6871        scheduler.advance_all(&mut stations);
6872        let mut drained = Vec::new();
6873        scheduler
6874            .drain_ready_events_into(&stations, &mut router, &mut drained)
6875            .expect("drain should work");
6876        assert_eq!(drained, vec![event]);
6877        assert_eq!(bridge.stats().events_sent, 1);
6878        assert_eq!(bridge.stats().events_routed, 1);
6879        assert_eq!(transport.stats().packets_sent, 1);
6880        assert_eq!(transport.stats().packets_received, 1);
6881    }
6882
6883    #[test]
6884    fn command_dispatch_transport_bridge_enqueues_stamped_command() {
6885        let gateway_station = StationId::new(0);
6886        let target_station = StationId::new(2);
6887        let command = CommandEnvelope {
6888            id: CommandId::new(42),
6889            client_id: ClientId::new(7),
6890            entity_id: EntityId::new(100),
6891            sequence: 42,
6892            received_at: Tick::new(12),
6893            kind: 1,
6894            priority: CommandPriority::High,
6895            payload: b"move:north".to_vec(),
6896        };
6897        let mut transport = InMemoryStationTransport::default();
6898        transport.register_station(target_station);
6899        let mut queues = BTreeMap::from([(target_station, command_queues())]);
6900        let mut bridge = CommandDispatchTransportBridge::default();
6901
6902        bridge
6903            .send_envelope(&mut transport, gateway_station, target_station, &command)
6904            .expect("command dispatch should send");
6905        assert_eq!(transport.queued_len(target_station), Some(1));
6906        let report = bridge
6907            .pump_target(
6908                &mut transport,
6909                &mut queues,
6910                target_station,
6911                4,
6912                CommandIngress::RUNNING,
6913            )
6914            .expect("command dispatch should pump");
6915
6916        assert_eq!(report.packets_received, 1);
6917        assert_eq!(report.commands_enqueued, 1);
6918        let queued_command = queues
6919            .get_mut(&target_station)
6920            .expect("queue should exist")
6921            .pop_next()
6922            .expect("command should queue");
6923        assert_eq!(queued_command, command);
6924        assert_eq!(bridge.stats().commands_sent, 1);
6925        assert_eq!(bridge.stats().commands_enqueued, 1);
6926        assert_eq!(transport.stats().packets_sent, 1);
6927        assert_eq!(transport.stats().packets_received, 1);
6928    }
6929
6930    #[test]
6931    fn command_dispatch_transport_bridge_rejects_endpoint_mismatch() {
6932        let packet_target = StationId::new(2);
6933        let frame_target = StationId::new(3);
6934        let mut transport = InMemoryStationTransport::default();
6935        transport.register_station(packet_target);
6936        let frame = CommandDispatchFrame {
6937            station_id: frame_target,
6938            client_id: ClientId::new(7),
6939            command_id: CommandId::new(42),
6940            entity_id: EntityId::new(100),
6941            sequence: 42,
6942            received_at: Tick::new(12),
6943            kind: 1,
6944            priority: CommandPriority::High,
6945            payload: Vec::new(),
6946        };
6947        let mut bytes = Vec::new();
6948        BinaryFrameEncoder
6949            .encode_command_dispatch(&frame, &mut bytes)
6950            .expect("frame should encode");
6951        transport
6952            .send_station(StationOutboundPacket {
6953                source_station: StationId::new(0),
6954                target_station: packet_target,
6955                bytes,
6956            })
6957            .expect("bad packet should enter transport");
6958        let mut queues = BTreeMap::from([(packet_target, command_queues())]);
6959        let mut bridge = CommandDispatchTransportBridge::default();
6960
6961        let error = bridge
6962            .pump_target(
6963                &mut transport,
6964                &mut queues,
6965                packet_target,
6966                4,
6967                CommandIngress::RUNNING,
6968            )
6969            .expect_err("endpoint mismatch should reject");
6970
6971        assert!(matches!(
6972            error,
6973            CommandDispatchTransportError::EndpointMismatch {
6974                packet_source,
6975                packet_target: observed_packet_target,
6976                dispatch_target,
6977            } if packet_source == StationId::new(0)
6978                && observed_packet_target == packet_target
6979                && dispatch_target == frame_target
6980        ));
6981        assert!(
6982            queues
6983                .get_mut(&packet_target)
6984                .expect("queue should exist")
6985                .pop_next()
6986                .is_none()
6987        );
6988    }
6989
6990    #[test]
6991    fn cell_migration_moves_owned_entities_and_updates_indexes() {
6992        let grid = GridSpec::new(16.0).expect("valid grid");
6993        let cell = CellCoord3::new(0, 0, 0);
6994        let mut stations = StationSet::default();
6995        let mut source = station(1, 10);
6996        let first = source
6997            .spawn_owned(
6998                EntityId::new(1),
6999                Position3::new(1.0, 1.0, 1.0),
7000                Bounds::Point,
7001                PolicyId::new(0),
7002            )
7003            .expect("first spawn should work");
7004        let second = source
7005            .spawn_owned(
7006                EntityId::new(2),
7007                Position3::new(2.0, 1.0, 1.0),
7008                Bounds::Point,
7009                PolicyId::new(0),
7010            )
7011            .expect("second spawn should work");
7012        stations.push(source);
7013        stations.push(station(2, 10));
7014
7015        let mut source_index = CellIndex::new(grid);
7016        source_index.upsert(first, Position3::new(1.0, 1.0, 1.0), Bounds::Point);
7017        source_index.upsert(second, Position3::new(2.0, 1.0, 1.0), Bounds::Point);
7018        let mut target_index = CellIndex::new(grid);
7019
7020        let mut ownership = CellOwnershipTable::default();
7021        ownership.assign(cell, StationId::new(1));
7022        let update = ownership.apply_split(
7023            &SplitProposal {
7024                source_station: StationId::new(1),
7025                cells_to_move: vec![cell],
7026                moved_pressure_score: 10,
7027            },
7028            StationId::new(2),
7029        );
7030        assert_eq!(ownership.owner_of(cell), Some(StationId::new(2)));
7031        assert_eq!(update.moved_cells, vec![cell]);
7032
7033        let mut scratch = CellMigrationScratch::new();
7034        scratch.reserve(2, 2);
7035        let mut report = CellMigrationReport::default();
7036        report.scanned_cells.reserve(1);
7037        report.entity_migrations.reserve(2);
7038        CellMigrationExecutor::migrate_cells_into(
7039            &mut stations,
7040            &mut source_index,
7041            &mut target_index,
7042            StationId::new(1),
7043            StationId::new(2),
7044            &update.moved_cells,
7045            4,
7046            &mut scratch,
7047            &mut report,
7048        )
7049        .expect("cell migration should work");
7050
7051        assert_eq!(report.entity_migrations.len(), 2);
7052        assert_eq!(target_index.entity_count(), 2);
7053        assert!(
7054            !stations
7055                .get(StationId::new(1))
7056                .expect("source")
7057                .get_by_id(EntityId::new(1))
7058                .expect("source ghost")
7059                .is_owned()
7060        );
7061        assert!(
7062            stations
7063                .get(StationId::new(2))
7064                .expect("target")
7065                .get_by_id(EntityId::new(1))
7066                .expect("target owner")
7067                .is_owned()
7068        );
7069
7070        let retained_handle_capacity = scratch.handle_capacity();
7071        let retained_entity_capacity = scratch.entity_capacity();
7072        let retained_candidate_capacity = scratch.candidate_capacity();
7073        let retained_scanned_capacity = report.scanned_cells.capacity();
7074        let retained_migration_capacity = report.entity_migrations.capacity();
7075        CellMigrationExecutor::migrate_cells_into(
7076            &mut stations,
7077            &mut source_index,
7078            &mut target_index,
7079            StationId::new(1),
7080            StationId::new(2),
7081            &[],
7082            4,
7083            &mut scratch,
7084            &mut report,
7085        )
7086        .expect("empty reusable migration should work");
7087        assert!(report.scanned_cells.is_empty());
7088        assert!(report.entity_migrations.is_empty());
7089        assert_eq!(report.scanned_cells.capacity(), retained_scanned_capacity);
7090        assert_eq!(
7091            report.entity_migrations.capacity(),
7092            retained_migration_capacity
7093        );
7094        assert_eq!(scratch.handle_capacity(), retained_handle_capacity);
7095        assert_eq!(scratch.entity_capacity(), retained_entity_capacity);
7096        assert_eq!(scratch.candidate_capacity(), retained_candidate_capacity);
7097    }
7098
7099    #[test]
7100    #[allow(clippy::too_many_lines)]
7101    fn split_scheduler_plans_and_executes_hot_cell_move() {
7102        let grid = GridSpec::new(16.0).expect("valid grid");
7103        let hot_cell = CellCoord3::new(0, 0, 0);
7104        let mut stations = StationSet::default();
7105        let mut source = station(1, 10);
7106        let handle = source
7107            .spawn_owned(
7108                EntityId::new(1),
7109                Position3::new(1.0, 1.0, 1.0),
7110                Bounds::Point,
7111                PolicyId::new(0),
7112            )
7113            .expect("spawn should work");
7114        stations.push(source);
7115        stations.push(station(2, 10));
7116
7117        let mut source_index = CellIndex::new(grid);
7118        source_index.upsert(handle, Position3::new(1.0, 1.0, 1.0), Bounds::Point);
7119        let mut indexes = StationIndexSet::default();
7120        indexes.insert(StationId::new(1), source_index);
7121        indexes.insert(StationId::new(2), CellIndex::new(grid));
7122
7123        let samples = vec![
7124            StationLoadSample {
7125                station_id: StationId::new(1),
7126                owned_entities: 100,
7127                subscribers: 100,
7128                tick_cost_units: 1000,
7129                cells: vec![CellLoadSample {
7130                    cell: hot_cell,
7131                    owned_entities: 100,
7132                    subscribers: 100,
7133                    event_pressure: 10,
7134                    ..CellLoadSample::default()
7135                }],
7136                ..StationLoadSample::default()
7137            },
7138            StationLoadSample {
7139                station_id: StationId::new(2),
7140                owned_entities: 1,
7141                cells: vec![CellLoadSample {
7142                    cell: CellCoord3::new(10, 0, 0),
7143                    owned_entities: 1,
7144                    ..CellLoadSample::default()
7145                }],
7146                ..StationLoadSample::default()
7147            },
7148        ];
7149        let scheduler = SplitScheduler::new(SplitSchedulerConfig {
7150            thresholds: HotspotThresholds {
7151                max_station_entities: 10,
7152                max_station_subscribers: 10,
7153                max_cell_pressure: 10,
7154                ..HotspotThresholds::default()
7155            },
7156            max_actions_per_pass: 1,
7157            max_cells_per_action: 1,
7158            ghost_ttl_ticks: 4,
7159            ..SplitSchedulerConfig::default()
7160        });
7161        let mut planning = SplitSchedulerScratch::new();
7162        let schedule =
7163            SplitSchedule::from(scheduler.plan_into(&samples, None, Tick::new(0), &mut planning));
7164        assert_eq!(schedule.actions.len(), 1);
7165        assert_eq!(schedule.actions[0].target_station, StationId::new(2));
7166
7167        let mut ownership = CellOwnershipTable::default();
7168        ownership.assign(hot_cell, StationId::new(1));
7169        let mut execution_scratch = SplitScheduleExecutionScratch::new();
7170        execution_scratch.reserve(1, 1, 1);
7171        {
7172            let report = scheduler
7173                .execute_into(
7174                    schedule.view(),
7175                    &mut stations,
7176                    &mut indexes,
7177                    &mut ownership,
7178                    &mut execution_scratch,
7179                )
7180                .expect("reusable execute should work");
7181            assert_eq!(report.cell_migrations.len(), 1);
7182            assert_eq!(report.cell_migrations[0].entity_migrations.len(), 1);
7183        }
7184
7185        assert_eq!(ownership.owner_of(hot_cell), Some(StationId::new(2)));
7186        assert_eq!(
7187            indexes
7188                .get(StationId::new(2))
7189                .expect("target index")
7190                .entity_count(),
7191            1
7192        );
7193
7194        let retained_ownership_slots = execution_scratch.retained_ownership_slots();
7195        let retained_migration_slots = execution_scratch.retained_migration_slots();
7196        let retained_update_cells = execution_scratch.retained_update_cell_capacity();
7197        let retained_entity_migrations = execution_scratch.retained_entity_migration_capacity();
7198        let retained_candidates = execution_scratch.retained_candidate_capacity();
7199        execution_scratch.reserve(1, 1, 1);
7200        assert_eq!(
7201            execution_scratch.retained_update_cell_capacity(),
7202            retained_update_cells
7203        );
7204        assert_eq!(
7205            execution_scratch.retained_entity_migration_capacity(),
7206            retained_entity_migrations
7207        );
7208        assert_eq!(
7209            execution_scratch.retained_candidate_capacity(),
7210            retained_candidates
7211        );
7212        let empty = SplitSchedule::default();
7213        let empty_report = scheduler
7214            .execute_into(
7215                empty.view(),
7216                &mut stations,
7217                &mut indexes,
7218                &mut ownership,
7219                &mut execution_scratch,
7220            )
7221            .expect("empty reusable execute should work");
7222        assert!(empty_report.ownership_updates.is_empty());
7223        assert!(empty_report.cell_migrations.is_empty());
7224        assert_eq!(
7225            execution_scratch.retained_ownership_slots(),
7226            retained_ownership_slots
7227        );
7228        assert_eq!(
7229            execution_scratch.retained_migration_slots(),
7230            retained_migration_slots
7231        );
7232        assert_eq!(
7233            execution_scratch.retained_update_cell_capacity(),
7234            retained_update_cells
7235        );
7236        assert_eq!(
7237            execution_scratch.retained_entity_migration_capacity(),
7238            retained_entity_migrations
7239        );
7240        assert_eq!(
7241            execution_scratch.retained_candidate_capacity(),
7242            retained_candidates
7243        );
7244    }
7245
7246    #[test]
7247    fn split_scheduler_respects_source_cooldown() {
7248        let hot_cell = CellCoord3::new(0, 0, 0);
7249        let samples = split_test_samples(hot_cell);
7250        let scheduler = SplitScheduler::new(SplitSchedulerConfig {
7251            thresholds: split_test_thresholds(),
7252            max_actions_per_pass: 1,
7253            max_cells_per_action: 1,
7254            split_cooldown_ticks: 10,
7255            ..SplitSchedulerConfig::default()
7256        });
7257        let mut state = SplitSchedulerState::default();
7258
7259        let mut scratch = SplitSchedulerScratch::new();
7260        let initial = SplitSchedule::from(scheduler.plan_into(
7261            &samples,
7262            Some(&state),
7263            Tick::new(5),
7264            &mut scratch,
7265        ));
7266        assert_eq!(initial.actions.len(), 1);
7267        state.record_schedule(&initial, Tick::new(5));
7268
7269        let cooled_down = scheduler.plan_into(&samples, Some(&state), Tick::new(8), &mut scratch);
7270        assert!(cooled_down.actions.is_empty());
7271        assert_eq!(cooled_down.skipped_cooldown, 1);
7272
7273        let after_cooldown =
7274            scheduler.plan_into(&samples, Some(&state), Tick::new(16), &mut scratch);
7275        assert_eq!(after_cooldown.actions.len(), 1);
7276    }
7277
7278    #[test]
7279    fn split_scheduler_reports_capacity_and_improvement_skips() {
7280        let hot_cell = CellCoord3::new(0, 0, 0);
7281        let samples = split_test_samples(hot_cell);
7282
7283        let capacity_guard = SplitScheduler::new(SplitSchedulerConfig {
7284            thresholds: split_test_thresholds(),
7285            max_actions_per_pass: 1,
7286            max_cells_per_action: 1,
7287            max_target_score_after_move: 1,
7288            ..SplitSchedulerConfig::default()
7289        });
7290        let mut scratch = SplitSchedulerScratch::new();
7291        let capacity_schedule =
7292            capacity_guard.plan_into(&samples, None, Tick::new(0), &mut scratch);
7293        assert!(capacity_schedule.actions.is_empty());
7294        assert_eq!(capacity_schedule.skipped_target_capacity, 1);
7295
7296        let improvement_guard = SplitScheduler::new(SplitSchedulerConfig {
7297            thresholds: split_test_thresholds(),
7298            max_actions_per_pass: 1,
7299            max_cells_per_action: 1,
7300            min_score_improvement: u64::MAX,
7301            ..SplitSchedulerConfig::default()
7302        });
7303        let improvement_schedule =
7304            improvement_guard.plan_into(&samples, None, Tick::new(0), &mut scratch);
7305        assert!(improvement_schedule.actions.is_empty());
7306        assert_eq!(improvement_schedule.skipped_insufficient_improvement, 1);
7307    }
7308
7309    #[test]
7310    fn split_scheduler_view_matches_owned_and_retains_nested_capacity() {
7311        let hot_cell = CellCoord3::new(0, 0, 0);
7312        let samples = split_test_samples(hot_cell);
7313        let scheduler = SplitScheduler::new(SplitSchedulerConfig {
7314            thresholds: split_test_thresholds(),
7315            max_actions_per_pass: 1,
7316            max_cells_per_action: 1,
7317            ..SplitSchedulerConfig::default()
7318        });
7319        let mut scratch = SplitSchedulerScratch::new();
7320        let expected =
7321            SplitSchedule::from(scheduler.plan_into(&samples, None, Tick::new(0), &mut scratch));
7322
7323        {
7324            let view = scheduler.plan_into(&samples, None, Tick::new(0), &mut scratch);
7325            assert_eq!(SplitSchedule::from(view), expected);
7326        }
7327        let decision_slots = scratch.retained_decision_slots();
7328        let action_slots = scratch.retained_action_slots();
7329        let reason_capacity = scratch.retained_reason_capacity();
7330        let action_cell_capacity = scratch.retained_action_cell_capacity();
7331        let candidate_capacity = scratch.retained_candidate_capacity();
7332        assert_eq!(decision_slots, samples.len());
7333        assert_eq!(action_slots, 1);
7334        assert!(reason_capacity > 0);
7335        assert!(action_cell_capacity > 0);
7336        assert!(candidate_capacity > 0);
7337
7338        let reduced = scheduler.plan_into(&samples[1..], None, Tick::new(0), &mut scratch);
7339        assert_eq!(reduced.decisions.len(), 1);
7340        assert!(reduced.actions.is_empty());
7341        assert_eq!(scratch.retained_decision_slots(), decision_slots);
7342        assert_eq!(scratch.retained_action_slots(), action_slots);
7343        assert_eq!(scratch.retained_reason_capacity(), reason_capacity);
7344        assert_eq!(
7345            scratch.retained_action_cell_capacity(),
7346            action_cell_capacity
7347        );
7348        assert_eq!(scratch.retained_candidate_capacity(), candidate_capacity);
7349    }
7350
7351    fn split_test_thresholds() -> HotspotThresholds {
7352        HotspotThresholds {
7353            max_station_entities: 10,
7354            max_station_subscribers: 10,
7355            max_cell_pressure: 10,
7356            ..HotspotThresholds::default()
7357        }
7358    }
7359
7360    fn split_test_samples(hot_cell: CellCoord3) -> Vec<StationLoadSample> {
7361        vec![
7362            StationLoadSample {
7363                station_id: StationId::new(1),
7364                owned_entities: 100,
7365                subscribers: 100,
7366                tick_cost_units: 1000,
7367                cells: vec![CellLoadSample {
7368                    cell: hot_cell,
7369                    owned_entities: 100,
7370                    subscribers: 100,
7371                    event_pressure: 10,
7372                    ..CellLoadSample::default()
7373                }],
7374                ..StationLoadSample::default()
7375            },
7376            StationLoadSample {
7377                station_id: StationId::new(2),
7378                owned_entities: 1,
7379                cells: vec![CellLoadSample {
7380                    cell: CellCoord3::new(10, 0, 0),
7381                    owned_entities: 1,
7382                    ..CellLoadSample::default()
7383                }],
7384                ..StationLoadSample::default()
7385            },
7386        ]
7387    }
7388}