Skip to main content

steel_core/server/
tick_rate_manager.rs

1use std::time::Instant;
2use steel_utils::TICKS_PER_SECOND;
3
4/// Number of tick samples to keep for averaging (matches vanilla).
5const TICK_STATS_SPAN: usize = 100;
6
7/// Nanoseconds per millisecond.
8const NANOS_PER_MS: f64 = 1_000_000.0;
9
10/// Nanoseconds per second.
11const NANOS_PER_SEC: f64 = 1_000_000_000.0;
12
13/// Milliseconds per second.
14const MS_PER_SEC: f64 = 1000.0;
15
16/// Smoothing factor for exponential moving average (matches vanilla's 0.8).
17const TICK_TIME_SMOOTHING: f32 = 0.8;
18
19/// Report data returned when a sprint finishes.
20#[derive(Debug, Clone)]
21pub struct SprintReport {
22    /// Ticks per second achieved during the sprint.
23    pub ticks_per_second: i32,
24    /// Milliseconds per tick during the sprint.
25    pub ms_per_tick: f64,
26}
27
28/// Manages the server tick rate, including freezing, stepping, and sprinting.
29pub struct TickRateManager {
30    /// The current tick rate in ticks per second.
31    pub tick_rate: f32,
32    /// The number of nanoseconds per tick based on the tick rate.
33    pub nanoseconds_per_tick: u64,
34    /// The current server tick count, including ticks where game elements are frozen.
35    pub tick_count: u64,
36    /// Whether the server is currently frozen.
37    is_frozen: bool,
38    /// The number of ticks to run while frozen (stepping).
39    frozen_ticks_to_run: i32,
40    /// Whether game elements should run this tick.
41    run_game_elements: bool,
42
43    // Sprinting
44    /// The number of ticks remaining to sprint.
45    remaining_sprint_ticks: i64,
46    /// The total number of ticks scheduled for the current sprint.
47    scheduled_current_sprint_ticks: i64,
48    /// The start time of the current sprint tick.
49    sprint_tick_start_time: Option<Instant>,
50    /// The total time spent sprinting in nanoseconds.
51    sprint_time_spent: i64,
52    /// Whether the server was frozen before sprinting started.
53    previous_is_frozen: bool,
54
55    // Tick time tracking (vanilla-style)
56    /// Rolling buffer of the last 100 tick times in nanoseconds.
57    tick_times_nanos: [u64; TICK_STATS_SPAN],
58    /// Aggregated sum of tick times for fast average calculation.
59    aggregated_tick_times_nanos: u64,
60    /// Exponentially smoothed tick time in milliseconds.
61    smoothed_tick_time_ms: f32,
62}
63
64impl TickRateManager {
65    /// Creates a new `TickRateManager` with the default tick rate (20.0 TPS).
66    #[must_use]
67    pub const fn new() -> Self {
68        Self {
69            tick_rate: TICKS_PER_SECOND as f32,
70            nanoseconds_per_tick: (NANOS_PER_SEC / TICKS_PER_SECOND as f64) as u64,
71            tick_count: 0,
72            is_frozen: false,
73            frozen_ticks_to_run: 0,
74            run_game_elements: true,
75            remaining_sprint_ticks: 0,
76            scheduled_current_sprint_ticks: 0,
77            sprint_tick_start_time: None,
78            sprint_time_spent: 0,
79            previous_is_frozen: false,
80            tick_times_nanos: [0; TICK_STATS_SPAN],
81            aggregated_tick_times_nanos: 0,
82            smoothed_tick_time_ms: 0.0,
83        }
84    }
85
86    /// Sets the tick rate.
87    pub fn set_tick_rate(&mut self, rate: f32) {
88        self.tick_rate = rate.max(1.0);
89        #[expect(
90            clippy::cast_possible_truncation,
91            clippy::cast_sign_loss,
92            reason = "result is always a positive sub-second nanosecond count, fits in u64"
93        )]
94        {
95            self.nanoseconds_per_tick = (NANOS_PER_SEC / f64::from(self.tick_rate)) as u64;
96        }
97    }
98
99    /// Returns the tick rate.
100    #[must_use]
101    pub const fn tick_rate(&self) -> f32 {
102        self.tick_rate
103    }
104
105    /// Returns milliseconds per tick based on the current tick rate.
106    #[must_use]
107    pub fn milliseconds_per_tick(&self) -> f32 {
108        self.nanoseconds_per_tick as f32 / NANOS_PER_MS as f32
109    }
110
111    /// Sets the frozen state of the server.
112    pub const fn set_frozen(&mut self, frozen: bool) {
113        self.is_frozen = frozen;
114    }
115
116    /// Returns whether the server is frozen.
117    #[must_use]
118    pub const fn is_frozen(&self) -> bool {
119        self.is_frozen
120    }
121
122    /// Returns whether the server is currently stepping forward.
123    #[must_use]
124    pub const fn is_stepping_forward(&self) -> bool {
125        self.frozen_ticks_to_run > 0
126    }
127
128    /// Returns the number of frozen ticks to run.
129    #[must_use]
130    pub const fn frozen_ticks_to_run(&self) -> i32 {
131        self.frozen_ticks_to_run
132    }
133
134    /// Returns whether game elements should run this tick.
135    #[must_use]
136    pub const fn runs_normally(&self) -> bool {
137        self.run_game_elements
138    }
139
140    /// Updates the state for the current tick.
141    /// Call this at the start of each server tick.
142    pub const fn tick(&mut self) {
143        self.run_game_elements = !self.is_frozen || self.frozen_ticks_to_run > 0;
144        if self.frozen_ticks_to_run > 0 {
145            self.frozen_ticks_to_run -= 1;
146        }
147    }
148
149    /// Increments the server tick count.
150    pub const fn increment_tick_count(&mut self) {
151        self.tick_count += 1;
152    }
153
154    // Stepping logic (for /tick step)
155
156    /// Steps the game forward by the given number of ticks if paused.
157    /// Returns true if stepping was started, false if the game is not frozen.
158    pub const fn step_game_if_paused(&mut self, ticks: i32) -> bool {
159        if !self.is_frozen {
160            return false;
161        }
162        self.frozen_ticks_to_run = ticks;
163        true
164    }
165
166    /// Stops the current step operation.
167    /// Returns true if stepping was stopped, false if not stepping.
168    pub const fn stop_stepping(&mut self) -> bool {
169        if self.frozen_ticks_to_run > 0 {
170            self.frozen_ticks_to_run = 0;
171            true
172        } else {
173            false
174        }
175    }
176
177    // Sprinting logic
178
179    /// Returns whether the server is currently sprinting.
180    #[must_use]
181    pub const fn is_sprinting(&self) -> bool {
182        self.scheduled_current_sprint_ticks > 0
183    }
184
185    /// Requests the game to sprint for a given number of ticks.
186    /// Returns true if an existing sprint was interrupted.
187    pub fn request_game_to_sprint(&mut self, ticks: i32) -> bool {
188        let interrupted = self.remaining_sprint_ticks > 0;
189        self.sprint_time_spent = 0;
190        self.scheduled_current_sprint_ticks = i64::from(ticks);
191        self.remaining_sprint_ticks = i64::from(ticks);
192        self.previous_is_frozen = self.is_frozen;
193        self.set_frozen(false);
194        interrupted
195    }
196
197    /// Stops the current sprint.
198    /// Returns the sprint report if a sprint was stopped, None otherwise.
199    pub fn stop_sprinting(&mut self) -> Option<SprintReport> {
200        if self.remaining_sprint_ticks > 0 {
201            Some(self.finish_tick_sprint())
202        } else {
203            None
204        }
205    }
206
207    /// Checks if the server should sprint this tick.
208    /// Returns Some(report) when the sprint finishes, None otherwise.
209    /// The bool indicates whether we should sprint (skip sleep).
210    pub fn check_should_sprint_this_tick(&mut self) -> (bool, Option<SprintReport>) {
211        if !self.run_game_elements {
212            return (false, None);
213        }
214        if self.remaining_sprint_ticks > 0 {
215            self.sprint_tick_start_time = Some(Instant::now());
216            self.remaining_sprint_ticks -= 1;
217            (true, None)
218        } else if self.scheduled_current_sprint_ticks > 0 {
219            // Sprint just finished
220            (false, Some(self.finish_tick_sprint()))
221        } else {
222            (false, None)
223        }
224    }
225
226    /// Ends the work for the current tick sprint.
227    /// Call this at the end of each tick during a sprint.
228    pub fn end_tick_work(&mut self) {
229        if let Some(start) = self.sprint_tick_start_time.take() {
230            self.sprint_time_spent += start.elapsed().as_nanos() as i64;
231        }
232    }
233
234    /// Finishes the current tick sprint and returns the sprint report.
235    fn finish_tick_sprint(&mut self) -> SprintReport {
236        let completed_ticks = self.scheduled_current_sprint_ticks - self.remaining_sprint_ticks;
237        let time_spent_ms = (self.sprint_time_spent.max(1) as f64) / NANOS_PER_MS;
238
239        #[expect(
240            clippy::cast_possible_truncation,
241            reason = "TPS fits well within i32 range"
242        )]
243        let ticks_per_second = (MS_PER_SEC * completed_ticks as f64 / time_spent_ms) as i32;
244        let ms_per_tick = if completed_ticks == 0 {
245            f64::from(self.milliseconds_per_tick())
246        } else {
247            time_spent_ms / completed_ticks as f64
248        };
249
250        self.scheduled_current_sprint_ticks = 0;
251        self.sprint_time_spent = 0;
252        self.remaining_sprint_ticks = 0;
253        self.set_frozen(self.previous_is_frozen);
254
255        SprintReport {
256            ticks_per_second,
257            ms_per_tick,
258        }
259    }
260
261    // Tick time tracking methods (vanilla-style)
262
263    /// Records the duration of a tick in nanoseconds.
264    /// This should be called at the end of each server tick.
265    pub fn record_tick_time(&mut self, tick_time_nanos: u64) {
266        let tick_index = (self.tick_count as usize) % TICK_STATS_SPAN;
267
268        // Remove old value from aggregated sum, add new value
269        self.aggregated_tick_times_nanos -= self.tick_times_nanos[tick_index];
270        self.aggregated_tick_times_nanos += tick_time_nanos;
271        self.tick_times_nanos[tick_index] = tick_time_nanos;
272
273        // Update smoothed tick time (vanilla uses 80/20 exponential smoothing)
274        let tick_time_ms = tick_time_nanos as f32 / NANOS_PER_MS as f32;
275        self.smoothed_tick_time_ms = self.smoothed_tick_time_ms * TICK_TIME_SMOOTHING
276            + tick_time_ms * (1.0 - TICK_TIME_SMOOTHING);
277    }
278
279    /// Returns the average tick time in nanoseconds over the last 100 ticks.
280    #[must_use]
281    pub fn get_average_tick_time_nanos(&self) -> u64 {
282        let sample_count = self.tick_count.min(TICK_STATS_SPAN as u64).max(1);
283        self.aggregated_tick_times_nanos / sample_count
284    }
285
286    /// Returns the average tick time in milliseconds over the last 100 ticks.
287    #[must_use]
288    pub fn get_average_mspt(&self) -> f32 {
289        self.get_average_tick_time_nanos() as f32 / NANOS_PER_MS as f32
290    }
291
292    /// Returns the exponentially smoothed tick time in milliseconds.
293    #[must_use]
294    pub const fn get_smoothed_mspt(&self) -> f32 {
295        self.smoothed_tick_time_ms
296    }
297
298    /// Returns the current TPS (ticks per second) based on average MSPT.
299    /// Capped at the configured tick rate (default 20.0).
300    #[must_use]
301    pub fn get_tps(&self) -> f32 {
302        let mspt = self.get_average_mspt();
303        if mspt <= 0.0 {
304            return self.tick_rate;
305        }
306        // TPS = 1000ms / mspt, but capped at the configured tick rate
307        (MS_PER_SEC as f32 / mspt).min(self.tick_rate)
308    }
309
310    /// Returns a copy of the tick times array for percentile calculation.
311    #[must_use]
312    pub const fn get_tick_times_nanos(&self) -> [u64; TICK_STATS_SPAN] {
313        self.tick_times_nanos
314    }
315
316    // Percentile methods (vanilla-style, used by /tick query)
317
318    /// Returns the P50 (median) tick time in milliseconds.
319    #[must_use]
320    pub fn get_p50(&self) -> f32 {
321        self.get_percentile(50)
322    }
323
324    /// Returns the P95 tick time in milliseconds.
325    #[must_use]
326    pub fn get_p95(&self) -> f32 {
327        self.get_percentile(95)
328    }
329
330    /// Returns the P99 tick time in milliseconds.
331    #[must_use]
332    pub fn get_p99(&self) -> f32 {
333        self.get_percentile(99)
334    }
335
336    /// Returns the number of tick samples currently available.
337    #[must_use]
338    pub fn get_sample_count(&self) -> usize {
339        (self.tick_count as usize).min(TICK_STATS_SPAN)
340    }
341
342    /// Returns the tick time at a given percentile in milliseconds.
343    fn get_percentile(&self, percentile: u8) -> f32 {
344        let sample_count = self.get_sample_count();
345        if sample_count == 0 {
346            return 0.0;
347        }
348
349        // Copy and sort only the valid samples
350        let mut sorted = self.tick_times_nanos;
351        sorted[..sample_count].sort_unstable();
352
353        let idx = (sample_count * percentile as usize / 100).min(sample_count - 1);
354        sorted[idx] as f32 / NANOS_PER_MS as f32
355    }
356}
357
358impl Default for TickRateManager {
359    fn default() -> Self {
360        Self::new()
361    }
362}