Skip to main content

steel_core/command/builtins/
time.rs

1//! Per-world clock command.
2//!
3//! Vanilla applies clock mutations server-wide. Steel intentionally applies
4//! them only to the command source's world so multiple worlds in one domain can
5//! keep independent timelines. Use `execute in <world> run time ...` to target
6//! a different world.
7
8use 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}