Skip to main content

steel_core/server/
tick_rate_manager.rs

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