Skip to main content

steel_core/command/builtins/
tick.rs

1use steel_utils::{Identifier, translations};
2use text_components::TextComponent;
3
4use super::super::{
5    brigadier::{ArgumentType, CommandNodeBuilder, CommandSyntaxError},
6    execution::{
7        CommandSource, SteelArgumentType, SteelCommandContext, SteelCommandRuntime, argument,
8        literal,
9    },
10    registration::CommandRegistration,
11};
12
13pub(super) fn registration() -> CommandRegistration<CommandSource> {
14    CommandRegistration::new(Identifier::vanilla_static("tick"), |_| command())
15        .subcommand_permission(["rate"])
16        .subcommand_permission(["step"])
17        .subcommand_permission(["sprint"])
18        .subcommand_permission(["unfreeze"])
19        .subcommand_permission(["freeze"])
20}
21
22fn command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
23    literal("tick")
24        .then(literal("query").executes(query_tick))
25        .then(
26            literal("rate")
27                .then(argument("rate", ArgumentType::float(1.0, 10_000.0)).executes(set_tick_rate)),
28        )
29        .then(
30            literal("step")
31                .executes(|context| step(context, 1))
32                .then(literal("stop").executes(stop_step))
33                .then(
34                    argument("time", SteelArgumentType::time(1)).executes(|context| {
35                        let Some(ticks) = context.time("time") else {
36                            return Err(missing_argument("time"));
37                        };
38                        step(context, ticks)
39                    }),
40                ),
41        )
42        .then(
43            literal("sprint")
44                .then(literal("stop").executes(stop_sprint))
45                .then(
46                    argument("time", SteelArgumentType::time(1)).executes(|context| {
47                        let Some(ticks) = context.time("time") else {
48                            return Err(missing_argument("time"));
49                        };
50                        sprint(context, ticks)
51                    }),
52                ),
53        )
54        .then(literal("unfreeze").executes(|context| set_frozen(context, false)))
55        .then(literal("freeze").executes(|context| set_frozen(context, true)))
56}
57
58fn nanos_to_millis_string(nanos: u64) -> String {
59    format!("{:.1}", nanos as f64 / 1_000_000.0)
60}
61
62enum TickStatus {
63    Sprinting,
64    Frozen,
65    Lagging,
66    Running,
67}
68
69#[expect(
70    clippy::cast_possible_truncation,
71    clippy::unnecessary_wraps,
72    reason = "the bounded rate is intentionally truncated and executors share a fallible signature"
73)]
74fn query_tick(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
75    let (tick_rate, busy_time_nanos, target_time_nanos, status, mut samples, sample_count) = {
76        let manager = context.source().server().tick_rate_manager.read();
77        let busy_time_nanos = manager.get_average_tick_time_nanos();
78        let status = if manager.is_sprinting() {
79            TickStatus::Sprinting
80        } else if manager.is_frozen() {
81            TickStatus::Frozen
82        } else if manager.nanoseconds_per_tick < busy_time_nanos {
83            TickStatus::Lagging
84        } else {
85            TickStatus::Running
86        };
87        (
88            manager.tick_rate(),
89            busy_time_nanos,
90            manager.nanoseconds_per_tick,
91            status,
92            manager.get_tick_times_nanos(),
93            manager.get_sample_count(),
94        )
95    };
96
97    let tick_rate_string = format!("{tick_rate:.1}");
98    let busy_time = nanos_to_millis_string(busy_time_nanos);
99    let status_message = match status {
100        TickStatus::Sprinting => None,
101        TickStatus::Frozen => Some(&translations::COMMANDS_TICK_STATUS_FROZEN),
102        TickStatus::Lagging => Some(&translations::COMMANDS_TICK_STATUS_LAGGING),
103        TickStatus::Running => Some(&translations::COMMANDS_TICK_STATUS_RUNNING),
104    };
105    if let Some(status_message) = status_message {
106        context
107            .source()
108            .send_success(&TextComponent::from(status_message), false);
109        let message = translations::COMMANDS_TICK_QUERY_RATE_RUNNING
110            .message([
111                tick_rate_string,
112                busy_time,
113                nanos_to_millis_string(target_time_nanos),
114            ])
115            .component();
116        context.source().send_success(&message, false);
117    } else {
118        context.source().send_success(
119            &TextComponent::from(&translations::COMMANDS_TICK_STATUS_SPRINTING),
120            false,
121        );
122        let message = translations::COMMANDS_TICK_QUERY_RATE_SPRINTING
123            .message([tick_rate_string, busy_time])
124            .component();
125        context.source().send_success(&message, false);
126    }
127
128    samples[..sample_count].sort_unstable();
129    let percentile = |numerator: usize| {
130        if sample_count == 0 {
131            return "0.0".to_owned();
132        }
133        let index = (sample_count * numerator / 100).min(sample_count - 1);
134        nanos_to_millis_string(samples[index])
135    };
136    let message = translations::COMMANDS_TICK_QUERY_PERCENTILES
137        .message([
138            percentile(50),
139            percentile(95),
140            percentile(99),
141            sample_count.to_string(),
142        ])
143        .component();
144    context.source().send_success(&message, false);
145    Ok(tick_rate as i32)
146}
147
148#[expect(
149    clippy::cast_possible_truncation,
150    reason = "the bounded tick rate intentionally returns its truncated command result"
151)]
152fn set_tick_rate(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
153    let Some(rate) = context.float("rate") else {
154        return Err(missing_argument("rate"));
155    };
156    context
157        .source()
158        .server()
159        .tick_rate_manager
160        .write()
161        .set_tick_rate(rate);
162    context.source().server().broadcast_ticking_state();
163
164    let message = translations::COMMANDS_TICK_RATE_SUCCESS
165        .message([format!("{rate:.1}")])
166        .component();
167    context.source().send_success(&message, true);
168    Ok(rate as i32)
169}
170
171#[expect(
172    clippy::unnecessary_wraps,
173    reason = "Command executors use a shared fallible callback signature."
174)]
175fn set_frozen(
176    context: &SteelCommandContext<CommandSource>,
177    frozen: bool,
178) -> Result<i32, CommandSyntaxError> {
179    let (sprint_report, stopped_step) = {
180        let mut manager = context.source().server().tick_rate_manager.write();
181        let sprint_report = if frozen {
182            manager.stop_sprinting()
183        } else {
184            None
185        };
186        let stopped_step = frozen && manager.stop_stepping();
187        manager.set_frozen(frozen);
188        (sprint_report, stopped_step)
189    };
190
191    if let Some(report) = sprint_report {
192        context.source().server().broadcast_sprint_report(&report);
193    }
194    if stopped_step {
195        context.source().server().broadcast_ticking_step();
196    }
197    context.source().server().broadcast_ticking_state();
198
199    let status = if frozen {
200        &translations::COMMANDS_TICK_STATUS_FROZEN
201    } else {
202        &translations::COMMANDS_TICK_STATUS_RUNNING
203    };
204    context
205        .source()
206        .send_success(&TextComponent::from(status), true);
207    Ok(i32::from(frozen))
208}
209
210#[expect(
211    clippy::unnecessary_wraps,
212    reason = "Command executors use a shared fallible callback signature."
213)]
214fn step(
215    context: &SteelCommandContext<CommandSource>,
216    ticks: i32,
217) -> Result<i32, CommandSyntaxError> {
218    let success = context
219        .source()
220        .server()
221        .tick_rate_manager
222        .write()
223        .step_game_if_paused(ticks);
224    if success {
225        context.source().server().broadcast_ticking_step();
226        let message = translations::COMMANDS_TICK_STEP_SUCCESS
227            .message([ticks.to_string()])
228            .component();
229        context.source().send_success(&message, true);
230    } else {
231        context
232            .source()
233            .send_failure(TextComponent::from(&translations::COMMANDS_TICK_STEP_FAIL));
234    }
235    Ok(1)
236}
237
238#[expect(
239    clippy::unnecessary_wraps,
240    reason = "Command executors use a shared fallible callback signature."
241)]
242fn stop_step(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
243    let stopped = context
244        .source()
245        .server()
246        .tick_rate_manager
247        .write()
248        .stop_stepping();
249    if stopped {
250        context.source().server().broadcast_ticking_step();
251        context.source().send_success(
252            &TextComponent::from(&translations::COMMANDS_TICK_STEP_STOP_SUCCESS),
253            true,
254        );
255        Ok(1)
256    } else {
257        context.source().send_failure(TextComponent::from(
258            &translations::COMMANDS_TICK_STEP_STOP_FAIL,
259        ));
260        Ok(0)
261    }
262}
263
264#[expect(
265    clippy::unnecessary_wraps,
266    reason = "Command executors use a shared fallible callback signature."
267)]
268fn sprint(
269    context: &SteelCommandContext<CommandSource>,
270    ticks: i32,
271) -> Result<i32, CommandSyntaxError> {
272    let interrupted = context
273        .source()
274        .server()
275        .tick_rate_manager
276        .write()
277        .request_game_to_sprint(ticks);
278    context.source().server().broadcast_ticking_state();
279    if interrupted {
280        context.source().send_success(
281            &TextComponent::from(&translations::COMMANDS_TICK_SPRINT_STOP_SUCCESS),
282            true,
283        );
284    }
285    context.source().send_success(
286        &TextComponent::from(&translations::COMMANDS_TICK_STATUS_SPRINTING),
287        true,
288    );
289    Ok(1)
290}
291
292#[expect(
293    clippy::unnecessary_wraps,
294    reason = "Command executors use a shared fallible callback signature."
295)]
296fn stop_sprint(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
297    let report = context
298        .source()
299        .server()
300        .tick_rate_manager
301        .write()
302        .stop_sprinting();
303    if let Some(report) = report {
304        context.source().server().broadcast_sprint_report(&report);
305        context.source().server().broadcast_ticking_state();
306        context.source().send_success(
307            &TextComponent::from(&translations::COMMANDS_TICK_SPRINT_STOP_SUCCESS),
308            true,
309        );
310        Ok(1)
311    } else {
312        context.source().send_failure(TextComponent::from(
313            &translations::COMMANDS_TICK_SPRINT_STOP_FAIL,
314        ));
315        Ok(0)
316    }
317}
318
319fn missing_argument(name: &str) -> CommandSyntaxError {
320    CommandSyntaxError::dynamic(format!(
321        "Parsed `{name}` is missing from the tick command context"
322    ))
323}
324
325#[cfg(test)]
326mod tests {
327    use super::super::create_dispatcher;
328    use crate::command::{
329        brigadier::{ArgumentType, CommandDispatcher, NodeId},
330        execution::{CommandSource, SteelArgumentType, SteelCommandRuntime},
331    };
332    use steel_registry::init_vanilla_registry;
333
334    type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
335
336    fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
337        let Some(children) = dispatcher.children(parent) else {
338            panic!("parent node should exist");
339        };
340        let Some(child) = children.iter().copied().find(|child| {
341            dispatcher
342                .node(*child)
343                .is_some_and(|node| node.name() == name)
344        }) else {
345            panic!("child `{name}` should exist");
346        };
347        child
348    }
349
350    #[test]
351    fn tick_graph_matches_the_target_shape_and_permissions() {
352        init_vanilla_registry();
353        let Ok(dispatcher) = create_dispatcher() else {
354            panic!("built-in commands should register");
355        };
356        let tick = child(&dispatcher, dispatcher.root(), "tick");
357        let Some(children) = dispatcher.children(tick) else {
358            panic!("tick root should exist");
359        };
360        let names = children
361            .iter()
362            .map(|child| {
363                let Some(node) = dispatcher.node(*child) else {
364                    panic!("tick child should exist");
365                };
366                node.name()
367            })
368            .collect::<Vec<_>>();
369        assert_eq!(
370            names,
371            ["query", "rate", "step", "sprint", "unfreeze", "freeze"]
372        );
373
374        let query = child(&dispatcher, tick, "query");
375        assert!(
376            dispatcher
377                .node(query)
378                .is_some_and(|node| node.is_executable() && node.is_restricted())
379        );
380        for name in ["rate", "step", "sprint", "unfreeze", "freeze"] {
381            let node = child(&dispatcher, tick, name);
382            let Some(node) = dispatcher.node(node) else {
383                panic!("tick subcommand should exist");
384            };
385            assert!(node.is_restricted());
386        }
387
388        let rate = child(&dispatcher, tick, "rate");
389        let rate = child(&dispatcher, rate, "rate");
390        assert_eq!(
391            dispatcher.node(rate).and_then(|node| node.argument_type()),
392            Some(&SteelArgumentType::from(ArgumentType::float(1.0, 10_000.0)))
393        );
394
395        for parent in ["step", "sprint"] {
396            let parent = child(&dispatcher, tick, parent);
397            let time = child(&dispatcher, parent, "time");
398            assert_eq!(
399                dispatcher.node(time).and_then(|node| node.argument_type()),
400                Some(&SteelArgumentType::time(1))
401            );
402        }
403    }
404}