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