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).ok_or_else(|| {
121 CommandSyntaxError::dynamic(format!(
122 "Parsed world clock {name} is missing from the command context"
123 ))
124 }),
125 }
126}
127
128fn clock_total_ticks(
129 context: &SteelCommandContext<CommandSource>,
130 clock: WorldClockRef,
131) -> Result<i64, CommandSyntaxError> {
132 context
133 .source()
134 .world()
135 .clock_total_ticks(clock)
136 .ok_or_else(|| missing_clock(clock))
137}
138
139fn set_total_ticks(
140 context: &SteelCommandContext<CommandSource>,
141 selection: ClockSelection,
142) -> Result<i32, CommandSyntaxError> {
143 let clock = selected_clock(context, selection)?;
144 let Some(total_ticks) = context.time("time") else {
145 return Err(missing_argument("time"));
146 };
147 context
148 .source()
149 .world()
150 .set_clock_total_ticks(clock, i64::from(total_ticks))
151 .ok_or_else(|| missing_clock(clock))?;
152 let message = translations::COMMANDS_TIME_SET_ABSOLUTE
153 .message([clock.key.to_string(), total_ticks.to_string()])
154 .component();
155 context.source().send_success(&message, true);
156 Ok(total_ticks)
157}
158
159fn add_time(
160 context: &SteelCommandContext<CommandSource>,
161 selection: ClockSelection,
162) -> Result<i32, CommandSyntaxError> {
163 let clock = selected_clock(context, selection)?;
164 let Some(ticks) = context.time("time") else {
165 return Err(missing_argument("time"));
166 };
167 let total_ticks = context
168 .source()
169 .world()
170 .add_clock_ticks(clock, ticks)
171 .ok_or_else(|| missing_clock(clock))?;
172 let message = translations::COMMANDS_TIME_SET_ABSOLUTE
173 .message([clock.key.to_string(), total_ticks.to_string()])
174 .component();
175 context.source().send_success(&message, true);
176 Ok(wrap_time(total_ticks))
177}
178
179fn set_time_marker(
180 context: &SteelCommandContext<CommandSource>,
181 selection: ClockSelection,
182) -> Result<i32, CommandSyntaxError> {
183 let clock = selected_clock(context, selection)?;
184 let Some(marker) = context.identifier("timemarker") else {
185 return Err(missing_argument("timemarker"));
186 };
187 match context
188 .source()
189 .world()
190 .move_clock_to_time_marker(clock, marker)
191 {
192 Some(true) => {}
193 Some(false) => {
194 let message = missing_time_marker_message(clock, marker);
195 return Err(CommandSyntaxError::dynamic(message));
196 }
197 None => return Err(missing_clock(clock)),
198 }
199 let total_ticks = clock_total_ticks(context, clock)?;
200 let message = translations::COMMANDS_TIME_SET_TIME_MARKER
201 .message([clock.key.to_string(), marker.to_string()])
202 .component();
203 context.source().send_success(&message, true);
204 Ok(wrap_time(total_ticks))
205}
206
207fn set_paused(
208 context: &SteelCommandContext<CommandSource>,
209 selection: ClockSelection,
210 paused: bool,
211) -> Result<i32, CommandSyntaxError> {
212 let clock = selected_clock(context, selection)?;
213 context
214 .source()
215 .world()
216 .set_clock_paused(clock, paused)
217 .ok_or_else(|| missing_clock(clock))?;
218 let translation = if paused {
219 &translations::COMMANDS_TIME_PAUSE
220 } else {
221 &translations::COMMANDS_TIME_RESUME
222 };
223 let message = translation.message([clock.key.to_string()]).component();
224 context.source().send_success(&message, true);
225 Ok(1)
226}
227
228fn set_rate(
229 context: &SteelCommandContext<CommandSource>,
230 selection: ClockSelection,
231) -> Result<i32, CommandSyntaxError> {
232 let clock = selected_clock(context, selection)?;
233 let Some(rate) = context.float("rate") else {
234 return Err(missing_argument("rate"));
235 };
236 context
237 .source()
238 .world()
239 .set_clock_rate(clock, rate)
240 .ok_or_else(|| missing_clock(clock))?;
241 let message = translations::COMMANDS_TIME_RATE
242 .message([clock.key.to_string(), rate.to_string()])
243 .component();
244 context.source().send_success(&message, true);
245 Ok(1)
246}
247
248#[expect(
249 clippy::unnecessary_wraps,
250 reason = "Command executors use a shared fallible callback signature."
251)]
252fn query_game_time(
253 context: &SteelCommandContext<CommandSource>,
254) -> Result<i32, CommandSyntaxError> {
255 let game_time = context.source().world().game_time();
256 let message = translations::COMMANDS_TIME_QUERY_GAMETIME
257 .message([game_time.to_string()])
258 .component();
259 context.source().send_success(&message, false);
260 Ok(wrap_time(game_time))
261}
262
263fn query_time(
264 context: &SteelCommandContext<CommandSource>,
265 selection: ClockSelection,
266) -> Result<i32, CommandSyntaxError> {
267 let clock = selected_clock(context, selection)?;
268 let total_ticks = clock_total_ticks(context, clock)?;
269 let message = translations::COMMANDS_TIME_QUERY_ABSOLUTE
270 .message([clock.key.to_string(), total_ticks.to_string()])
271 .component();
272 context.source().send_success(&message, false);
273 Ok(wrap_time(total_ticks))
274}
275
276fn selected_timeline(
277 context: &SteelCommandContext<CommandSource>,
278 clock: WorldClockRef,
279) -> Result<TimelineRef, CommandSyntaxError> {
280 let Some(timeline) = context.timeline("timeline") else {
281 return Err(missing_argument("timeline"));
282 };
283 if timeline.clock != clock {
284 let message = wrong_timeline_for_clock_message(clock, timeline);
285 return Err(CommandSyntaxError::dynamic(message));
286 }
287 Ok(timeline)
288}
289
290fn missing_time_marker_message(clock: WorldClockRef, marker: &Identifier) -> TextComponent {
291 translations::COMMANDS_TIME_NO_TIME_MARKER_FOUND
292 .message([marker.to_string(), clock.key.to_string()])
293 .component()
294}
295
296fn wrong_timeline_for_clock_message(clock: WorldClockRef, timeline: TimelineRef) -> TextComponent {
297 translations::COMMANDS_TIME_WRONG_TIMELINE_FOR_CLOCK
298 .message([timeline.key.to_string(), clock.key.to_string()])
299 .component()
300}
301
302fn query_timeline(
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 current_ticks = timeline.current_ticks(clock_total_ticks(context, clock)?);
309 let message = translations::COMMANDS_TIME_QUERY_TIMELINE
310 .message([timeline.key.to_string(), current_ticks.to_string()])
311 .component();
312 context.source().send_success(&message, false);
313 Ok(wrap_time(current_ticks))
314}
315
316fn query_timeline_repetitions(
317 context: &SteelCommandContext<CommandSource>,
318 selection: ClockSelection,
319) -> Result<i32, CommandSyntaxError> {
320 let clock = selected_clock(context, selection)?;
321 let timeline = selected_timeline(context, clock)?;
322 let repetitions = timeline.period_count(clock_total_ticks(context, clock)?);
323 let message = translations::COMMANDS_TIME_QUERY_TIMELINE_REPETITIONS
324 .message([timeline.key.to_string(), repetitions.to_string()])
325 .component();
326 context.source().send_success(&message, false);
327 Ok(wrap_time(i64::from(repetitions)))
328}
329
330fn missing_argument(name: &str) -> CommandSyntaxError {
331 CommandSyntaxError::dynamic(format!(
332 "Parsed value for {name} is missing from the command context"
333 ))
334}
335
336fn missing_clock(clock: WorldClockRef) -> CommandSyntaxError {
337 CommandSyntaxError::dynamic(format!("World clock {} is not initialized", clock.key))
338}
339
340#[expect(
341 clippy::cast_possible_truncation,
342 reason = "the remainder is always within the signed 32-bit result range"
343)]
344fn wrap_time(ticks: i64) -> i32 {
345 (ticks % i64::from(i32::MAX)) as i32
346}
347
348#[cfg(test)]
349mod tests {
350 use super::super::create_dispatcher;
351 use super::{
352 CLOCK_ARGUMENT, missing_time_marker_message, wrap_time, wrong_timeline_for_clock_message,
353 };
354 use crate::command::{
355 brigadier::{ArgumentType, CommandDispatcher, NodeId},
356 execution::{CommandSource, SteelArgumentType, SteelCommandRuntime},
357 };
358 use steel_registry::{init_vanilla_registry, vanilla_timelines, vanilla_world_clocks};
359 use steel_utils::{Identifier, translations};
360
361 type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
362
363 fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
364 let Some(children) = dispatcher.children(parent) else {
365 panic!("parent node should exist");
366 };
367 let Some(child) = children.iter().copied().find(|child| {
368 dispatcher
369 .node(*child)
370 .is_some_and(|node| node.name() == name)
371 }) else {
372 panic!("child {name} should exist");
373 };
374 child
375 }
376
377 #[test]
378 fn time_graph_matches_the_26_2_clock_shape() {
379 init_vanilla_registry();
380 let Ok(dispatcher) = create_dispatcher() else {
381 panic!("built-in commands should register");
382 };
383 let root = child(&dispatcher, dispatcher.root(), "time");
384 let children = dispatcher.children(root).unwrap_or_default();
385 let names = children
386 .iter()
387 .filter_map(|child| {
388 let node = dispatcher.node(*child)?;
389 Some(node.name())
390 })
391 .collect::<Vec<_>>();
392 assert_eq!(
393 names,
394 ["set", "add", "pause", "resume", "rate", "query", "of"]
395 );
396
397 let set = child(&dispatcher, root, "set");
398 let time = child(&dispatcher, set, "time");
399 assert_eq!(
400 dispatcher.node(time).and_then(|node| node.argument_type()),
401 Some(&SteelArgumentType::time(0))
402 );
403 let marker = child(&dispatcher, set, "timemarker");
404 assert_eq!(
405 dispatcher
406 .node(marker)
407 .and_then(|node| node.argument_type()),
408 Some(&SteelArgumentType::time_marker(None))
409 );
410
411 let rate = child(&dispatcher, root, "rate");
412 let rate_value = child(&dispatcher, rate, "rate");
413 assert_eq!(
414 dispatcher
415 .node(rate_value)
416 .and_then(|node| node.argument_type()),
417 Some(&SteelArgumentType::from(ArgumentType::float(
418 1.0E-5, 1_000.0
419 )))
420 );
421
422 let of = child(&dispatcher, root, "of");
423 let clock = child(&dispatcher, of, CLOCK_ARGUMENT);
424 assert_eq!(
425 dispatcher.node(clock).and_then(|node| node.argument_type()),
426 Some(&SteelArgumentType::world_clock())
427 );
428 let of_set = child(&dispatcher, clock, "set");
429 let of_marker = child(&dispatcher, of_set, "timemarker");
430 assert_eq!(
431 dispatcher
432 .node(of_marker)
433 .and_then(|node| node.argument_type()),
434 Some(&SteelArgumentType::time_marker(Some("clock")))
435 );
436 }
437
438 #[test]
439 fn wrap_time_matches_vanilla_modulus() {
440 assert_eq!(wrap_time(0), 0);
441 assert_eq!(wrap_time(i64::from(i32::MAX)), 0);
442 assert_eq!(wrap_time(i64::from(i32::MAX) + 4), 4);
443 assert_eq!(wrap_time(-4), -4);
444 }
445
446 #[test]
447 fn time_marker_and_timeline_errors_use_vanillas_argument_order() {
448 let marker = Identifier::vanilla_static("missing");
449 assert_eq!(
450 missing_time_marker_message(&vanilla_world_clocks::OVERWORLD, &marker),
451 translations::COMMANDS_TIME_NO_TIME_MARKER_FOUND
452 .message(["minecraft:missing", "minecraft:overworld"])
453 .component()
454 );
455 assert_eq!(
456 wrong_timeline_for_clock_message(
457 &vanilla_world_clocks::THE_END,
458 &vanilla_timelines::DAY,
459 ),
460 translations::COMMANDS_TIME_WRONG_TIMELINE_FOR_CLOCK
461 .message(["minecraft:day", "minecraft:the_end"])
462 .component()
463 );
464 }
465}