Skip to main content

sectorsync_core/
command.rs

1//! Client command envelopes and validation decisions.
2
3use std::collections::VecDeque;
4
5use crate::barrier::{BarrierState, CommandQueueMode};
6use crate::ids::{ClientId, CommandId, EntityId, Tick};
7
8/// Client command priority.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum CommandPriority {
11    /// Normal gameplay command.
12    Normal,
13    /// Latency-sensitive command.
14    High,
15    /// Low-priority command that may be delayed under pressure.
16    Low,
17}
18
19/// Command envelope accepted by the generic command pipeline.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct CommandEnvelope {
22    /// Command id used for replay and audit.
23    pub id: CommandId,
24    /// Client that submitted the command.
25    pub client_id: ClientId,
26    /// Entity the command intends to control.
27    pub entity_id: EntityId,
28    /// Client-side sequence number.
29    pub sequence: u64,
30    /// Server tick observed when the command entered the runtime.
31    pub received_at: Tick,
32    /// Game-defined command kind.
33    pub kind: u32,
34    /// Command priority.
35    pub priority: CommandPriority,
36    /// Opaque payload owned by the embedding game.
37    pub payload: Vec<u8>,
38}
39
40/// Result of command validation.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum CommandDecision {
43    /// Command can be applied.
44    Accept,
45    /// Command is invalid and should not be applied.
46    Reject {
47        /// Machine-readable reject reason.
48        reason: CommandRejectReason,
49    },
50    /// Command should be treated as suspicious for audit purposes.
51    FlagSuspicious {
52        /// Suspicion score chosen by the embedding application.
53        score: u32,
54        /// Machine-readable reject or audit reason.
55        reason: CommandRejectReason,
56    },
57}
58
59/// Generic command reject reasons.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum CommandRejectReason {
62    /// Command failed schema or size validation.
63    InvalidSchema,
64    /// Command was submitted too frequently.
65    RateLimited,
66    /// Command was stale or replayed.
67    ReplayOrStale,
68    /// Command targeted an entity not owned by this station.
69    NotOwner,
70    /// Game-specific validator rejected the command.
71    GameRule,
72}
73
74/// Bounded queue limits by command priority.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct CommandQueueLimits {
77    /// High-priority queue capacity.
78    pub high: usize,
79    /// Normal-priority queue capacity.
80    pub normal: usize,
81    /// Low-priority queue capacity.
82    pub low: usize,
83}
84
85impl Default for CommandQueueLimits {
86    fn default() -> Self {
87        Self {
88            high: 1024,
89            normal: 8192,
90            low: 4096,
91        }
92    }
93}
94
95/// Barrier-aware command ingress policy.
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub struct CommandIngress {
98    /// Current barrier state.
99    pub barrier_state: BarrierState,
100    /// Command behavior configured for the active barrier.
101    pub command_mode: CommandQueueMode,
102}
103
104impl CommandIngress {
105    /// Normal running ingress.
106    pub const RUNNING: Self = Self {
107        barrier_state: BarrierState::Running,
108        command_mode: CommandQueueMode::Buffer,
109    };
110}
111
112/// Outcome of enqueueing a command.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum CommandPushOutcome {
115    /// Command was queued for normal application.
116    Queued,
117    /// Command was buffered while a barrier is active.
118    BufferedByBarrier,
119}
120
121/// Command queue error.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum CommandQueueError {
124    /// Queue is full and caller must apply backpressure.
125    QueueFull(CommandPriority),
126    /// Barrier mode rejects this command.
127    RejectedByBarrier(CommandQueueMode),
128}
129
130impl core::fmt::Display for CommandQueueError {
131    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
132        match self {
133            Self::QueueFull(priority) => write!(f, "{priority:?} command queue is full"),
134            Self::RejectedByBarrier(mode) => {
135                write!(f, "command rejected by active barrier mode {mode:?}")
136            }
137        }
138    }
139}
140
141impl std::error::Error for CommandQueueError {}
142
143/// Bounded, priority-aware command queues for a station or gateway shard.
144#[derive(Clone, Debug)]
145pub struct CommandQueues {
146    limits: CommandQueueLimits,
147    high: VecDeque<CommandEnvelope>,
148    normal: VecDeque<CommandEnvelope>,
149    low: VecDeque<CommandEnvelope>,
150    barrier_buffer: VecDeque<CommandEnvelope>,
151}
152
153impl CommandQueues {
154    /// Creates empty command queues.
155    pub fn new(limits: CommandQueueLimits) -> Self {
156        Self {
157            limits,
158            high: VecDeque::new(),
159            normal: VecDeque::new(),
160            low: VecDeque::new(),
161            barrier_buffer: VecDeque::new(),
162        }
163    }
164
165    /// Pushes a command through the barrier-aware ingress policy.
166    pub fn push(
167        &mut self,
168        command: CommandEnvelope,
169        ingress: CommandIngress,
170    ) -> Result<CommandPushOutcome, CommandQueueError> {
171        match ingress.barrier_state {
172            BarrierState::Running | BarrierState::Resuming => {
173                self.push_ready(command)?;
174                Ok(CommandPushOutcome::Queued)
175            }
176            BarrierState::Requested | BarrierState::WaitingTickBoundary | BarrierState::Frozen => {
177                match ingress.command_mode {
178                    CommandQueueMode::Buffer => {
179                        if self.barrier_buffer.len() >= self.barrier_buffer_capacity() {
180                            Err(CommandQueueError::QueueFull(command.priority))
181                        } else {
182                            self.barrier_buffer.push_back(command);
183                            Ok(CommandPushOutcome::BufferedByBarrier)
184                        }
185                    }
186                    CommandQueueMode::Reject | CommandQueueMode::Drain => {
187                        Err(CommandQueueError::RejectedByBarrier(ingress.command_mode))
188                    }
189                }
190            }
191        }
192    }
193
194    /// Moves commands buffered by a barrier back into priority queues.
195    ///
196    /// If a target priority queue is full, the first blocked command and all
197    /// commands after it remain in the barrier buffer for a later retry.
198    pub fn release_barrier_buffer(&mut self) -> Result<usize, CommandQueueError> {
199        let mut released = 0;
200        while let Some(priority) = self.barrier_buffer.front().map(|command| command.priority) {
201            if !self.has_ready_capacity(priority) {
202                return Err(CommandQueueError::QueueFull(priority));
203            }
204            let command = self
205                .barrier_buffer
206                .pop_front()
207                .expect("front command was checked above");
208            match priority {
209                CommandPriority::High => self.high.push_back(command),
210                CommandPriority::Normal => self.normal.push_back(command),
211                CommandPriority::Low => self.low.push_back(command),
212            }
213            released += 1;
214        }
215        Ok(released)
216    }
217
218    /// Drops commands buffered by a barrier.
219    pub fn clear_barrier_buffer(&mut self) -> usize {
220        let dropped = self.barrier_buffer.len();
221        self.barrier_buffer.clear();
222        dropped
223    }
224
225    /// Pops the next command, preferring high priority.
226    pub fn pop_next(&mut self) -> Option<CommandEnvelope> {
227        self.high
228            .pop_front()
229            .or_else(|| self.normal.pop_front())
230            .or_else(|| self.low.pop_front())
231    }
232
233    /// Returns queued command count excluding the barrier buffer.
234    pub fn ready_len(&self) -> usize {
235        self.high.len() + self.normal.len() + self.low.len()
236    }
237
238    /// Returns command count buffered by an active barrier.
239    pub fn barrier_buffer_len(&self) -> usize {
240        self.barrier_buffer.len()
241    }
242
243    /// Returns slots retained by one ready priority queue.
244    pub fn ready_retained_capacity(&self, priority: CommandPriority) -> usize {
245        match priority {
246            CommandPriority::High => self.high.capacity(),
247            CommandPriority::Normal => self.normal.capacity(),
248            CommandPriority::Low => self.low.capacity(),
249        }
250    }
251
252    /// Returns slots retained across all ready priority queues.
253    pub fn total_ready_retained_capacity(&self) -> usize {
254        self.high
255            .capacity()
256            .saturating_add(self.normal.capacity())
257            .saturating_add(self.low.capacity())
258    }
259
260    /// Returns slots retained by the barrier buffer.
261    pub fn barrier_buffer_retained_capacity(&self) -> usize {
262        self.barrier_buffer.capacity()
263    }
264
265    /// Releases unused retained queue storage without changing queued commands.
266    pub fn reclaim_retained_capacity(&mut self) {
267        self.high.shrink_to_fit();
268        self.normal.shrink_to_fit();
269        self.low.shrink_to_fit();
270        self.barrier_buffer.shrink_to_fit();
271    }
272
273    /// Maximum commands retained while a barrier buffers ingress.
274    ///
275    /// The buffer uses the saturating sum of the three ready-queue limits so
276    /// integrations get a bounded default without a separate hidden capacity.
277    pub fn barrier_buffer_capacity(&self) -> usize {
278        self.limits
279            .high
280            .saturating_add(self.limits.normal)
281            .saturating_add(self.limits.low)
282    }
283
284    /// Returns total command count including barrier buffer.
285    pub fn total_len(&self) -> usize {
286        self.ready_len() + self.barrier_buffer.len()
287    }
288
289    /// Returns whether no commands are queued.
290    pub fn is_empty(&self) -> bool {
291        self.total_len() == 0
292    }
293
294    fn push_ready(&mut self, command: CommandEnvelope) -> Result<(), CommandQueueError> {
295        if !self.has_ready_capacity(command.priority) {
296            return Err(CommandQueueError::QueueFull(command.priority));
297        }
298        match command.priority {
299            CommandPriority::High => {
300                self.high.push_back(command);
301            }
302            CommandPriority::Normal => {
303                self.normal.push_back(command);
304            }
305            CommandPriority::Low => {
306                self.low.push_back(command);
307            }
308        }
309        Ok(())
310    }
311
312    fn has_ready_capacity(&self, priority: CommandPriority) -> bool {
313        match priority {
314            CommandPriority::High => self.high.len() < self.limits.high,
315            CommandPriority::Normal => self.normal.len() < self.limits.normal,
316            CommandPriority::Low => self.low.len() < self.limits.low,
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn command(id: u64, priority: CommandPriority) -> CommandEnvelope {
326        CommandEnvelope {
327            id: CommandId::new(id),
328            client_id: ClientId::new(1),
329            entity_id: EntityId::new(10),
330            sequence: id,
331            received_at: Tick::new(0),
332            kind: 1,
333            priority,
334            payload: Vec::new(),
335        }
336    }
337
338    #[test]
339    fn command_queues_pop_by_priority() {
340        let mut queues = CommandQueues::new(CommandQueueLimits {
341            high: 2,
342            normal: 2,
343            low: 2,
344        });
345
346        queues
347            .push(command(1, CommandPriority::Low), CommandIngress::RUNNING)
348            .expect("low should queue");
349        queues
350            .push(command(2, CommandPriority::High), CommandIngress::RUNNING)
351            .expect("high should queue");
352        queues
353            .push(command(3, CommandPriority::Normal), CommandIngress::RUNNING)
354            .expect("normal should queue");
355
356        assert_eq!(queues.pop_next().expect("high").id, CommandId::new(2));
357        assert_eq!(queues.pop_next().expect("normal").id, CommandId::new(3));
358        assert_eq!(queues.pop_next().expect("low").id, CommandId::new(1));
359    }
360
361    #[test]
362    fn command_queues_allocate_lazily_and_retain_peak_capacity() {
363        let mut queues = CommandQueues::new(CommandQueueLimits::default());
364        assert_eq!(queues.total_ready_retained_capacity(), 0);
365        assert_eq!(queues.barrier_buffer_retained_capacity(), 0);
366
367        for (offset, priority) in [
368            CommandPriority::High,
369            CommandPriority::Normal,
370            CommandPriority::Low,
371        ]
372        .into_iter()
373        .enumerate()
374        {
375            for index in 0..8 {
376                queues
377                    .push(
378                        command(
379                            u64::try_from(offset * 8 + index).expect("test id fits u64"),
380                            priority,
381                        ),
382                        CommandIngress::RUNNING,
383                    )
384                    .expect("ready burst should queue");
385            }
386            assert!(queues.ready_retained_capacity(priority) >= 8);
387        }
388        let ready_peak = queues.total_ready_retained_capacity();
389        while queues.pop_next().is_some() {}
390        assert_eq!(queues.total_ready_retained_capacity(), ready_peak);
391
392        let frozen = CommandIngress {
393            barrier_state: BarrierState::Frozen,
394            command_mode: CommandQueueMode::Buffer,
395        };
396        for index in 0..8 {
397            queues
398                .push(command(100 + index, CommandPriority::Normal), frozen)
399                .expect("barrier burst should queue");
400        }
401        let barrier_peak = queues.barrier_buffer_retained_capacity();
402        assert!(barrier_peak >= 8);
403        assert_eq!(queues.clear_barrier_buffer(), 8);
404        assert_eq!(queues.barrier_buffer_retained_capacity(), barrier_peak);
405    }
406
407    #[test]
408    fn reclaim_retained_capacity_preserves_queued_order() {
409        let mut queues = CommandQueues::new(CommandQueueLimits::default());
410        queues
411            .push(command(1, CommandPriority::Low), CommandIngress::RUNNING)
412            .expect("low");
413        queues
414            .push(command(2, CommandPriority::High), CommandIngress::RUNNING)
415            .expect("high");
416
417        queues.reclaim_retained_capacity();
418
419        assert_eq!(queues.pop_next().expect("high first").id, CommandId::new(2));
420        assert_eq!(queues.pop_next().expect("low second").id, CommandId::new(1));
421    }
422
423    #[test]
424    fn barrier_buffer_mode_holds_and_releases_commands() {
425        let mut queues = CommandQueues::new(CommandQueueLimits::default());
426        let ingress = CommandIngress {
427            barrier_state: BarrierState::Frozen,
428            command_mode: CommandQueueMode::Buffer,
429        };
430
431        let outcome = queues
432            .push(command(1, CommandPriority::Normal), ingress)
433            .expect("buffer should work");
434        assert_eq!(outcome, CommandPushOutcome::BufferedByBarrier);
435        assert_eq!(queues.ready_len(), 0);
436        assert_eq!(queues.barrier_buffer_len(), 1);
437
438        assert_eq!(
439            queues
440                .release_barrier_buffer()
441                .expect("release should work"),
442            1
443        );
444        assert_eq!(queues.ready_len(), 1);
445    }
446
447    #[test]
448    fn barrier_reject_mode_rejects_commands() {
449        let mut queues = CommandQueues::new(CommandQueueLimits::default());
450        let ingress = CommandIngress {
451            barrier_state: BarrierState::Frozen,
452            command_mode: CommandQueueMode::Reject,
453        };
454
455        let error = queues
456            .push(command(1, CommandPriority::Normal), ingress)
457            .expect_err("reject mode should reject");
458        assert_eq!(
459            error,
460            CommandQueueError::RejectedByBarrier(CommandQueueMode::Reject)
461        );
462    }
463
464    #[test]
465    fn barrier_buffer_is_bounded_by_ready_queue_limits() {
466        let mut queues = CommandQueues::new(CommandQueueLimits {
467            high: 1,
468            normal: 0,
469            low: 0,
470        });
471        let ingress = CommandIngress {
472            barrier_state: BarrierState::Frozen,
473            command_mode: CommandQueueMode::Buffer,
474        };
475
476        assert_eq!(queues.barrier_buffer_capacity(), 1);
477        queues
478            .push(command(1, CommandPriority::High), ingress)
479            .expect("first command should buffer");
480        let error = queues
481            .push(command(2, CommandPriority::High), ingress)
482            .expect_err("bounded barrier buffer should reject overflow");
483
484        assert_eq!(error, CommandQueueError::QueueFull(CommandPriority::High));
485        assert_eq!(queues.barrier_buffer_len(), 1);
486    }
487
488    #[test]
489    fn failed_barrier_release_keeps_blocked_command_buffered() {
490        let mut queues = CommandQueues::new(CommandQueueLimits {
491            high: 1,
492            normal: 0,
493            low: 0,
494        });
495        queues
496            .push(command(1, CommandPriority::High), CommandIngress::RUNNING)
497            .expect("ready queue should accept first command");
498        queues
499            .push(
500                command(2, CommandPriority::High),
501                CommandIngress {
502                    barrier_state: BarrierState::Frozen,
503                    command_mode: CommandQueueMode::Buffer,
504                },
505            )
506            .expect("barrier buffer should accept second command");
507
508        let error = queues
509            .release_barrier_buffer()
510            .expect_err("full ready queue should block release");
511        assert_eq!(error, CommandQueueError::QueueFull(CommandPriority::High));
512        assert_eq!(queues.barrier_buffer_len(), 1);
513        assert_eq!(
514            queues.pop_next().expect("first command should remain").id,
515            CommandId::new(1)
516        );
517        assert_eq!(queues.release_barrier_buffer(), Ok(1));
518        assert_eq!(
519            queues.pop_next().expect("second command should release").id,
520            CommandId::new(2)
521        );
522    }
523}