1use steel_registry::{timeline::TimelineRef, world_clock::WorldClockRef};
9use steel_utils::{Identifier, translations};
10use text_components::TextComponent;
11
12use super::super::{
13 brigadier::{ArgumentType, CommandNodeBuilder, CommandSyntaxError},
14 execution::{
15 CommandSource, SteelArgumentType, SteelCommandContext, SteelCommandRuntime, argument,
16 literal,
17 },
18 registration::CommandRegistration,
19};
20
21const CLOCK_ARGUMENT: &str = "clock";
22
23pub(super) fn registration() -> CommandRegistration<CommandSource> {
24 CommandRegistration::new(Identifier::vanilla_static("time"), |_| command())
25}
26
27fn command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
28 let command = add_clock_nodes(literal("time"), ClockSelection::Default, true);
29 command.then(literal("of").then(add_clock_nodes(
30 argument(CLOCK_ARGUMENT, SteelArgumentType::world_clock()),
31 ClockSelection::Argument(CLOCK_ARGUMENT),
32 false,
33 )))
34}
35
36#[derive(Clone, Copy)]
37enum ClockSelection {
38 Default,
39 Argument(&'static str),
40}
41
42impl ClockSelection {
43 const fn argument_name(self) -> Option<&'static str> {
44 match self {
45 Self::Default => None,
46 Self::Argument(name) => Some(name),
47 }
48 }
49}
50
51fn add_clock_nodes(
52 node: CommandNodeBuilder<CommandSource, SteelCommandRuntime>,
53 selection: ClockSelection,
54 include_game_time: bool,
55) -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
56 let mut query = literal("query")
57 .then(literal("time").executes(move |context| query_time(context, selection)))
58 .then(
59 argument(
60 "timeline",
61 SteelArgumentType::timeline(selection.argument_name()),
62 )
63 .executes(move |context| query_timeline(context, selection))
64 .then(
65 literal("repetition")
66 .executes(move |context| query_timeline_repetitions(context, selection)),
67 ),
68 );
69 if include_game_time {
70 query = query.then(literal("gametime").executes(query_game_time));
71 }
72
73 node.then(
74 literal("set")
75 .then(
76 argument("time", SteelArgumentType::time(0))
77 .executes(move |context| set_total_ticks(context, selection)),
78 )
79 .then(
80 argument(
81 "timemarker",
82 SteelArgumentType::time_marker(selection.argument_name()),
83 )
84 .executes(move |context| set_time_marker(context, selection)),
85 ),
86 )
87 .then(
88 literal("add").then(
89 argument("time", SteelArgumentType::time(i32::MIN))
90 .executes(move |context| add_time(context, selection)),
91 ),
92 )
93 .then(literal("pause").executes(move |context| set_paused(context, selection, true)))
94 .then(literal("resume").executes(move |context| set_paused(context, selection, false)))
95 .then(
96 literal("rate").then(
97 argument("rate", ArgumentType::float(1.0E-5, 1_000.0))
98 .executes(move |context| set_rate(context, selection)),
99 ),
100 )
101 .then(query)
102}
103
104fn selected_clock(
105 context: &SteelCommandContext<CommandSource>,
106 selection: ClockSelection,
107) -> Result<WorldClockRef, CommandSyntaxError> {
108 match selection {
109 ClockSelection::Default => context
110 .source()
111 .world()
112 .dimension_type
113 .default_clock
114 .ok_or_else(|| {
115 let message = translations::COMMANDS_TIME_NO_DEFAULT_CLOCK
116 .message([context.source().world().dimension_type.key.to_string()])
117 .component();
118 CommandSyntaxError::dynamic(message)
119 }),
120 ClockSelection::Argument(name) => context.world_clock(name),
121 }
122}
123
124fn clock_total_ticks(
125 context: &SteelCommandContext<CommandSource>,
126 clock: WorldClockRef,
127) -> Result<i64, CommandSyntaxError> {
128 context
129 .source()
130 .world()
131 .clock_total_ticks(clock)
132 .ok_or_else(|| missing_clock(clock))
133}
134
135fn set_total_ticks(
136 context: &SteelCommandContext<CommandSource>,
137 selection: ClockSelection,
138) -> Result<i32, CommandSyntaxError> {
139 let clock = selected_clock(context, selection)?;
140 let total_ticks = context.time("time")?;
141 context
142 .source()
143 .world()
144 .set_clock_total_ticks(clock, i64::from(total_ticks))
145 .ok_or_else(|| missing_clock(clock))?;
146 let message = translations::COMMANDS_TIME_SET_ABSOLUTE
147 .message([clock.key.to_string(), total_ticks.to_string()])
148 .component();
149 context.source().send_success(&message, true);
150 Ok(total_ticks)
151}
152
153fn add_time(
154 context: &SteelCommandContext<CommandSource>,
155 selection: ClockSelection,
156) -> Result<i32, CommandSyntaxError> {
157 let clock = selected_clock(context, selection)?;
158 let ticks = context.time("time")?;
159 let total_ticks = context
160 .source()
161 .world()
162 .add_clock_ticks(clock, ticks)
163 .ok_or_else(|| missing_clock(clock))?;
164 let message = translations::COMMANDS_TIME_SET_ABSOLUTE
165 .message([clock.key.to_string(), total_ticks.to_string()])
166 .component();
167 context.source().send_success(&message, true);
168 Ok(wrap_time(total_ticks))
169}
170
171fn set_time_marker(
172 context: &SteelCommandContext<CommandSource>,
173 selection: ClockSelection,
174) -> Result<i32, CommandSyntaxError> {
175 let clock = selected_clock(context, selection)?;
176 let marker = context.identifier("timemarker")?;
177 match context
178 .source()
179 .world()
180 .move_clock_to_time_marker(clock, marker)
181 {
182 Some(true) => {}
183 Some(false) => {
184 let message = missing_time_marker_message(clock, marker);
185 return Err(CommandSyntaxError::dynamic(message));
186 }
187 None => return Err(missing_clock(clock)),
188 }
189 let total_ticks = clock_total_ticks(context, clock)?;
190 let message = translations::COMMANDS_TIME_SET_TIME_MARKER
191 .message([clock.key.to_string(), marker.to_string()])
192 .component();
193 context.source().send_success(&message, true);
194 Ok(wrap_time(total_ticks))
195}
196
197fn set_paused(
198 context: &SteelCommandContext<CommandSource>,
199 selection: ClockSelection,
200 paused: bool,
201) -> Result<i32, CommandSyntaxError> {
202 let clock = selected_clock(context, selection)?;
203 context
204 .source()
205 .world()
206 .set_clock_paused(clock, paused)
207 .ok_or_else(|| missing_clock(clock))?;
208 let translation = if paused {
209 &translations::COMMANDS_TIME_PAUSE
210 } else {
211 &translations::COMMANDS_TIME_RESUME
212 };
213 let message = translation.message([clock.key.to_string()]).component();
214 context.source().send_success(&message, true);
215 Ok(1)
216}
217
218fn set_rate(
219 context: &SteelCommandContext<CommandSource>,
220 selection: ClockSelection,
221) -> Result<i32, CommandSyntaxError> {
222 let clock = selected_clock(context, selection)?;
223 let rate = context.float("rate")?;
224 context
225 .source()
226 .world()
227 .set_clock_rate(clock, rate)
228 .ok_or_else(|| missing_clock(clock))?;
229 let message = translations::COMMANDS_TIME_RATE
230 .message([clock.key.to_string(), rate.to_string()])
231 .component();
232 context.source().send_success(&message, true);
233 Ok(1)
234}
235
236#[expect(
237 clippy::unnecessary_wraps,
238 reason = "Command executors use a shared fallible callback signature."
239)]
240fn query_game_time(
241 context: &SteelCommandContext<CommandSource>,
242) -> Result<i32, CommandSyntaxError> {
243 let game_time = context.source().world().game_time();
244 let message = translations::COMMANDS_TIME_QUERY_GAMETIME
245 .message([game_time.to_string()])
246 .component();
247 context.source().send_success(&message, false);
248 Ok(wrap_time(game_time))
249}
250
251fn query_time(
252 context: &SteelCommandContext<CommandSource>,
253 selection: ClockSelection,
254) -> Result<i32, CommandSyntaxError> {
255 let clock = selected_clock(context, selection)?;
256 let total_ticks = clock_total_ticks(context, clock)?;
257 let message = translations::COMMANDS_TIME_QUERY_ABSOLUTE
258 .message([clock.key.to_string(), total_ticks.to_string()])
259 .component();
260 context.source().send_success(&message, false);
261 Ok(wrap_time(total_ticks))
262}
263
264fn selected_timeline(
265 context: &SteelCommandContext<CommandSource>,
266 clock: WorldClockRef,
267) -> Result<TimelineRef, CommandSyntaxError> {
268 let timeline = context.timeline("timeline")?;
269 if timeline.clock != clock {
270 let message = wrong_timeline_for_clock_message(clock, timeline);
271 return Err(CommandSyntaxError::dynamic(message));
272 }
273 Ok(timeline)
274}
275
276fn missing_time_marker_message(clock: WorldClockRef, marker: &Identifier) -> TextComponent {
277 translations::COMMANDS_TIME_NO_TIME_MARKER_FOUND
278 .message([marker.to_string(), clock.key.to_string()])
279 .component()
280}
281
282fn wrong_timeline_for_clock_message(clock: WorldClockRef, timeline: TimelineRef) -> TextComponent {
283 translations::COMMANDS_TIME_WRONG_TIMELINE_FOR_CLOCK
284 .message([timeline.key.to_string(), clock.key.to_string()])
285 .component()
286}
287
288fn query_timeline(
289 context: &SteelCommandContext<CommandSource>,
290 selection: ClockSelection,
291) -> Result<i32, CommandSyntaxError> {
292 let clock = selected_clock(context, selection)?;
293 let timeline = selected_timeline(context, clock)?;
294 let current_ticks = timeline.current_ticks(clock_total_ticks(context, clock)?);
295 let message = translations::COMMANDS_TIME_QUERY_TIMELINE
296 .message([timeline.key.to_string(), current_ticks.to_string()])
297 .component();
298 context.source().send_success(&message, false);
299 Ok(wrap_time(current_ticks))
300}
301
302fn query_timeline_repetitions(
303 context: &SteelCommandContext<CommandSource>,
304 selection: ClockSelection,
305) -> Result<i32, CommandSyntaxError> {
306 let clock = selected_clock(context, selection)?;
307 let timeline = selected_timeline(context, clock)?;
308 let repetitions = timeline.period_count(clock_total_ticks(context, clock)?);
309 let message = translations::COMMANDS_TIME_QUERY_TIMELINE_REPETITIONS
310 .message([timeline.key.to_string(), repetitions.to_string()])
311 .component();
312 context.source().send_success(&message, false);
313 Ok(wrap_time(i64::from(repetitions)))
314}
315
316fn missing_clock(clock: WorldClockRef) -> CommandSyntaxError {
317 CommandSyntaxError::dynamic(format!("World clock {} is not initialized", clock.key))
318}
319
320#[expect(
321 clippy::cast_possible_truncation,
322 reason = "the remainder is always within the signed 32-bit result range"
323)]
324fn wrap_time(ticks: i64) -> i32 {
325 (ticks % i64::from(i32::MAX)) as i32
326}
327
328#[cfg(test)]
329mod tests {
330 use super::super::create_dispatcher;
331 use super::{
332 CLOCK_ARGUMENT, missing_time_marker_message, wrap_time, wrong_timeline_for_clock_message,
333 };
334 use crate::command::{
335 brigadier::{ArgumentType, CommandDispatcher, NodeId},
336 execution::{CommandSource, SteelArgumentType, SteelCommandRuntime},
337 };
338 use steel_registry::{init_vanilla_registry, vanilla_timelines, vanilla_world_clocks};
339 use steel_utils::{Identifier, translations};
340
341 type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
342
343 fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
344 let Some(children) = dispatcher.children(parent) else {
345 panic!("parent node should exist");
346 };
347 let Some(child) = children.iter().copied().find(|child| {
348 dispatcher
349 .node(*child)
350 .is_some_and(|node| node.name() == name)
351 }) else {
352 panic!("child {name} should exist");
353 };
354 child
355 }
356
357 #[test]
358 fn time_graph_matches_the_26_2_clock_shape() {
359 init_vanilla_registry();
360 let Ok(dispatcher) = create_dispatcher() else {
361 panic!("built-in commands should register");
362 };
363 let root = child(&dispatcher, dispatcher.root(), "time");
364 let children = dispatcher.children(root).unwrap_or_default();
365 let names = children
366 .iter()
367 .filter_map(|child| {
368 let node = dispatcher.node(*child)?;
369 Some(node.name())
370 })
371 .collect::<Vec<_>>();
372 assert_eq!(
373 names,
374 ["set", "add", "pause", "resume", "rate", "query", "of"]
375 );
376
377 let set = child(&dispatcher, root, "set");
378 let time = child(&dispatcher, set, "time");
379 assert_eq!(
380 dispatcher.node(time).and_then(|node| node.argument_type()),
381 Some(&SteelArgumentType::time(0))
382 );
383 let marker = child(&dispatcher, set, "timemarker");
384 assert_eq!(
385 dispatcher
386 .node(marker)
387 .and_then(|node| node.argument_type()),
388 Some(&SteelArgumentType::time_marker(None))
389 );
390
391 let rate = child(&dispatcher, root, "rate");
392 let rate_value = child(&dispatcher, rate, "rate");
393 assert_eq!(
394 dispatcher
395 .node(rate_value)
396 .and_then(|node| node.argument_type()),
397 Some(&SteelArgumentType::from(ArgumentType::float(
398 1.0E-5, 1_000.0
399 )))
400 );
401
402 let of = child(&dispatcher, root, "of");
403 let clock = child(&dispatcher, of, CLOCK_ARGUMENT);
404 assert_eq!(
405 dispatcher.node(clock).and_then(|node| node.argument_type()),
406 Some(&SteelArgumentType::world_clock())
407 );
408 let of_set = child(&dispatcher, clock, "set");
409 let of_marker = child(&dispatcher, of_set, "timemarker");
410 assert_eq!(
411 dispatcher
412 .node(of_marker)
413 .and_then(|node| node.argument_type()),
414 Some(&SteelArgumentType::time_marker(Some("clock")))
415 );
416 }
417
418 #[test]
419 fn wrap_time_matches_vanilla_modulus() {
420 assert_eq!(wrap_time(0), 0);
421 assert_eq!(wrap_time(i64::from(i32::MAX)), 0);
422 assert_eq!(wrap_time(i64::from(i32::MAX) + 4), 4);
423 assert_eq!(wrap_time(-4), -4);
424 }
425
426 #[test]
427 fn time_marker_and_timeline_errors_use_vanillas_argument_order() {
428 let marker = Identifier::vanilla_static("missing");
429 assert_eq!(
430 missing_time_marker_message(&vanilla_world_clocks::OVERWORLD, &marker),
431 translations::COMMANDS_TIME_NO_TIME_MARKER_FOUND
432 .message(["minecraft:missing", "minecraft:overworld"])
433 .component()
434 );
435 assert_eq!(
436 wrong_timeline_for_clock_message(
437 &vanilla_world_clocks::THE_END,
438 &vanilla_timelines::DAY,
439 ),
440 translations::COMMANDS_TIME_WRONG_TIMELINE_FOR_CLOCK
441 .message(["minecraft:day", "minecraft:the_end"])
442 .component()
443 );
444 }
445}