steel_core/command/builtins/
tick.rs1use 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}