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),
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}