Skip to main content

steel_core/command/builtins/
tick.rs

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