Skip to main content

steel_core/command/builtins/
tick.rs

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