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