Skip to main content

steel_core/command/builtins/
worldborder.rs

1//! World border command.
2
3use steel_utils::{Identifier, translations};
4use text_components::{TextComponent, translation::Translation};
5
6use super::super::{
7    brigadier::{ArgumentType, CommandNodeBuilder, CommandSyntaxError},
8    execution::{
9        CommandSource, SteelArgumentType, SteelCommandContext, SteelCommandRuntime, argument,
10        literal,
11    },
12    registration::CommandRegistration,
13};
14use crate::world::{MAX_CENTER_COORDINATE, MAX_SIZE, WorldBorderError};
15
16const MIN_SIZE: f64 = 1.0;
17
18pub(super) fn registration() -> CommandRegistration<CommandSource> {
19    CommandRegistration::new(Identifier::vanilla_static("worldborder"), |_| command())
20}
21
22fn command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
23    literal("worldborder")
24        .then(size_command("add", add_size))
25        .then(size_command("set", set_requested_size))
26        .then(
27            literal("center")
28                .then(argument("pos", SteelArgumentType::vec2(true)).executes(set_center)),
29        )
30        .then(
31            literal("damage")
32                .then(
33                    literal("amount").then(
34                        argument("damagePerBlock", ArgumentType::float(0.0, f32::MAX))
35                            .executes(set_damage_amount),
36                    ),
37                )
38                .then(
39                    literal("buffer").then(
40                        argument("distance", ArgumentType::float(0.0, f32::MAX))
41                            .executes(set_damage_buffer),
42                    ),
43                ),
44        )
45        .then(literal("get").executes(get_size))
46        .then(
47            literal("warning")
48                .then(
49                    literal("distance").then(
50                        argument("distance", ArgumentType::integer(0, i32::MAX))
51                            .executes(set_warning_distance),
52                    ),
53                )
54                .then(
55                    literal("time").then(
56                        argument("time", SteelArgumentType::time(0)).executes(set_warning_time),
57                    ),
58                ),
59        )
60}
61
62fn size_command(
63    name: &'static str,
64    execute: fn(
65        &SteelCommandContext<CommandSource>,
66        Option<i64>,
67    ) -> Result<i32, CommandSyntaxError>,
68) -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
69    literal(name).then(
70        argument("distance", ArgumentType::double(-MAX_SIZE, MAX_SIZE))
71            .executes(move |context| execute(context, None))
72            .then(
73                argument("time", SteelArgumentType::time(0)).executes(move |context| {
74                    let ticks = i64::from(context.time("time")?);
75                    execute(context, Some(ticks))
76                }),
77            ),
78    )
79}
80
81fn add_size(
82    context: &SteelCommandContext<CommandSource>,
83    requested_ticks: Option<i64>,
84) -> Result<i32, CommandSyntaxError> {
85    let border = context.source().world().world_border_snapshot();
86    let distance = border.old_size + context.double("distance")?;
87    let ticks = added_lerp_time(border.lerp_time, requested_ticks);
88    set_size(context, distance, ticks)
89}
90
91const fn added_lerp_time(current_ticks: i64, requested_ticks: Option<i64>) -> i64 {
92    match requested_ticks {
93        Some(ticks) => current_ticks.wrapping_add(ticks),
94        None => 0,
95    }
96}
97
98fn set_requested_size(
99    context: &SteelCommandContext<CommandSource>,
100    ticks: Option<i64>,
101) -> Result<i32, CommandSyntaxError> {
102    set_size(context, context.double("distance")?, ticks.unwrap_or(0))
103}
104
105#[expect(
106    clippy::float_cmp,
107    reason = "Vanilla rejects only exactly unchanged border sizes."
108)]
109fn set_size(
110    context: &SteelCommandContext<CommandSource>,
111    distance: f64,
112    ticks: i64,
113) -> Result<i32, CommandSyntaxError> {
114    let world = context.source().world();
115    let current = world.world_border_snapshot().old_size;
116    if current == distance {
117        return Err(translated_error(
118            &translations::COMMANDS_WORLDBORDER_SET_FAILED_NOCHANGE,
119        ));
120    }
121    if distance < MIN_SIZE {
122        return Err(translated_error(
123            &translations::COMMANDS_WORLDBORDER_SET_FAILED_SMALL,
124        ));
125    }
126    if distance > MAX_SIZE {
127        let message = translations::COMMANDS_WORLDBORDER_SET_FAILED_BIG
128            .message([format!("{MAX_SIZE:.6E}")])
129            .component();
130        return Err(CommandSyntaxError::dynamic(message));
131    }
132
133    let formatted_distance = format!("{distance:.1}");
134    let message = if ticks > 0 {
135        world
136            .lerp_world_border_size_between(current, distance, ticks)
137            .map_err(internal_border_error)?;
138        let seconds = format_ticks_to_seconds(ticks);
139        if distance > current {
140            translations::COMMANDS_WORLDBORDER_SET_GROW
141                .message([formatted_distance, seconds])
142                .component()
143        } else {
144            translations::COMMANDS_WORLDBORDER_SET_SHRINK
145                .message([formatted_distance, seconds])
146                .component()
147        }
148    } else {
149        world
150            .set_world_border_size(distance)
151            .map_err(internal_border_error)?;
152        translations::COMMANDS_WORLDBORDER_SET_IMMEDIATE
153            .message([formatted_distance])
154            .component()
155    };
156    context.source().send_success(&message, true);
157    Ok((distance - current) as i32)
158}
159
160#[expect(
161    clippy::cast_possible_truncation,
162    clippy::float_cmp,
163    reason = "Vanilla resolves Vec2 components as f32 and compares exact center values."
164)]
165fn set_center(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
166    let position = context.coordinates("pos")?.position(context.source());
167    let center_x = f64::from(position.x as f32);
168    let center_z = f64::from(position.z as f32);
169    let world = context.source().world();
170    let border = world.world_border_snapshot();
171    if border.center_x == center_x && border.center_z == center_z {
172        return Err(translated_error(
173            &translations::COMMANDS_WORLDBORDER_CENTER_FAILED,
174        ));
175    }
176    if center_x.abs() > MAX_CENTER_COORDINATE || center_z.abs() > MAX_CENTER_COORDINATE {
177        let message = translations::COMMANDS_WORLDBORDER_SET_FAILED_FAR
178            .message([format!("{MAX_CENTER_COORDINATE:.7E}")])
179            .component();
180        return Err(CommandSyntaxError::dynamic(message));
181    }
182
183    world
184        .set_world_border_center(center_x, center_z)
185        .map_err(internal_border_error)?;
186    let message = translations::COMMANDS_WORLDBORDER_CENTER_SUCCESS
187        .message([format!("{center_x:.2}"), format!("{center_z:.2}")])
188        .component();
189    context.source().send_success(&message, true);
190    Ok(0)
191}
192
193#[expect(
194    clippy::float_cmp,
195    reason = "Vanilla rejects only an exactly unchanged damage buffer."
196)]
197fn set_damage_buffer(
198    context: &SteelCommandContext<CommandSource>,
199) -> Result<i32, CommandSyntaxError> {
200    let distance = context.float("distance")?;
201    let world = context.source().world();
202    if world.world_border_snapshot().safe_zone == f64::from(distance) {
203        return Err(translated_error(
204            &translations::COMMANDS_WORLDBORDER_DAMAGE_BUFFER_FAILED,
205        ));
206    }
207    world
208        .set_world_border_safe_zone(f64::from(distance))
209        .map_err(internal_border_error)?;
210    let message = translations::COMMANDS_WORLDBORDER_DAMAGE_BUFFER_SUCCESS
211        .message([format!("{distance:.2}")])
212        .component();
213    context.source().send_success(&message, true);
214    Ok(distance as i32)
215}
216
217#[expect(
218    clippy::float_cmp,
219    reason = "Vanilla rejects only an exactly unchanged damage amount."
220)]
221fn set_damage_amount(
222    context: &SteelCommandContext<CommandSource>,
223) -> Result<i32, CommandSyntaxError> {
224    let damage = context.float("damagePerBlock")?;
225    let world = context.source().world();
226    if world.world_border_snapshot().damage_per_block == f64::from(damage) {
227        return Err(translated_error(
228            &translations::COMMANDS_WORLDBORDER_DAMAGE_AMOUNT_FAILED,
229        ));
230    }
231    world
232        .set_world_border_damage_per_block(f64::from(damage))
233        .map_err(internal_border_error)?;
234    let message = translations::COMMANDS_WORLDBORDER_DAMAGE_AMOUNT_SUCCESS
235        .message([format!("{damage:.2}")])
236        .component();
237    context.source().send_success(&message, true);
238    Ok(damage as i32)
239}
240
241fn set_warning_time(
242    context: &SteelCommandContext<CommandSource>,
243) -> Result<i32, CommandSyntaxError> {
244    let ticks = context.time("time")?;
245    let world = context.source().world();
246    if world.world_border_snapshot().warning_time == ticks {
247        return Err(translated_error(
248            &translations::COMMANDS_WORLDBORDER_WARNING_TIME_FAILED,
249        ));
250    }
251    world.set_world_border_warning_time(ticks);
252    let message = translations::COMMANDS_WORLDBORDER_WARNING_TIME_SUCCESS
253        .message([format_ticks_to_seconds(i64::from(ticks))])
254        .component();
255    context.source().send_success(&message, true);
256    Ok(ticks)
257}
258
259fn set_warning_distance(
260    context: &SteelCommandContext<CommandSource>,
261) -> Result<i32, CommandSyntaxError> {
262    let distance = context.integer("distance")?;
263    let world = context.source().world();
264    if world.world_border_snapshot().warning_blocks == distance {
265        return Err(translated_error(
266            &translations::COMMANDS_WORLDBORDER_WARNING_DISTANCE_FAILED,
267        ));
268    }
269    world.set_world_border_warning_blocks(distance);
270    let message = translations::COMMANDS_WORLDBORDER_WARNING_DISTANCE_SUCCESS
271        .message([distance.to_string()])
272        .component();
273    context.source().send_success(&message, true);
274    Ok(distance)
275}
276
277#[expect(
278    clippy::cast_possible_truncation,
279    clippy::unnecessary_wraps,
280    reason = "Vanilla returns a signed result and command executors share a fallible signature."
281)]
282fn get_size(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
283    let size = context.source().world().world_border_snapshot().old_size;
284    let message = translations::COMMANDS_WORLDBORDER_GET
285        .message([format!("{size:.0}")])
286        .component();
287    context.source().send_success(&message, false);
288    Ok((size + 0.5).floor() as i32)
289}
290
291fn format_ticks_to_seconds(ticks: i64) -> String {
292    format!("{:.2}", ticks as f64 / 20.0)
293}
294
295fn translated_error(translation: &Translation<0>) -> CommandSyntaxError {
296    CommandSyntaxError::dynamic(TextComponent::from(translation))
297}
298
299fn internal_border_error(error: WorldBorderError) -> CommandSyntaxError {
300    CommandSyntaxError::dynamic(TextComponent::from(error.to_string()))
301}
302
303#[cfg(test)]
304mod tests {
305    use super::{
306        MAX_CENTER_COORDINATE, MAX_SIZE, added_lerp_time, format_ticks_to_seconds, size_command,
307    };
308    use crate::command::{
309        brigadier::{ArgumentType, CommandDispatcher},
310        execution::{CommandSource, SteelArgumentType, SteelCommandRuntime},
311    };
312
313    #[test]
314    fn formats_ticks_as_vanilla_seconds() {
315        assert_eq!(format_ticks_to_seconds(0), "0.00");
316        assert_eq!(format_ticks_to_seconds(31), "1.55");
317    }
318
319    #[test]
320    fn formats_limits_like_vanilla_numeric_components() {
321        assert_eq!(format!("{MAX_SIZE:.6E}"), "5.999997E7");
322        assert_eq!(format!("{MAX_CENTER_COORDINATE:.7E}"), "2.9999984E7");
323    }
324
325    #[test]
326    fn add_preserves_existing_lerp_only_when_time_is_explicit() {
327        assert_eq!(added_lerp_time(40, None), 0);
328        assert_eq!(added_lerp_time(40, Some(0)), 40);
329        assert_eq!(added_lerp_time(40, Some(20)), 60);
330    }
331
332    #[test]
333    fn size_subcommands_accept_optional_time() {
334        let mut dispatcher = CommandDispatcher::<CommandSource, SteelCommandRuntime>::new();
335        let Ok(root) = dispatcher.register(size_command("set", |_, _| Ok(1))) else {
336            panic!("size command should register");
337        };
338        let Some(distance) = dispatcher
339            .children(root)
340            .and_then(|children| children.first())
341        else {
342            panic!("distance argument should exist");
343        };
344        assert_eq!(
345            dispatcher
346                .node(*distance)
347                .and_then(|node| node.argument_type()),
348            Some(&SteelArgumentType::from(ArgumentType::double(
349                -super::MAX_SIZE,
350                super::MAX_SIZE,
351            )))
352        );
353        let Some(time) = dispatcher
354            .children(*distance)
355            .and_then(|children| children.first())
356        else {
357            panic!("time argument should exist");
358        };
359        assert_eq!(
360            dispatcher.node(*time).and_then(|node| node.argument_type()),
361            Some(&SteelArgumentType::time(0))
362        );
363    }
364}