Skip to main content

steel_core/command/builtins/
domain.rs

1//! Steel domain switch command.
2
3use std::sync::Arc;
4
5use steel_registry::{
6    data_components::vanilla_components::{CUSTOM_NAME, ENCHANTMENT_GLINT_OVERRIDE},
7    vanilla_dimension_types, vanilla_items, vanilla_menu_types,
8};
9use steel_utils::Identifier;
10use text_components::TextComponent;
11
12use crate::{
13    entity::Entity as _,
14    inventory::prelude::*,
15    portal::{PreparedSpawn, WorldEntry},
16    server::Server,
17    world::World,
18};
19
20use super::super::{
21    brigadier::{CommandNodeBuilder, CommandSyntaxError},
22    execution::{
23        CommandSource, SteelArgumentType, SteelCommandContext, SteelCommandRuntime, argument,
24        literal,
25    },
26    registration::CommandRegistration,
27};
28use super::position::{ensure_spawnable_position, finite_rotation, wrap_rotation};
29
30pub(super) fn registration() -> CommandRegistration<CommandSource> {
31    CommandRegistration::new(Identifier::from_steel("domain"), |_| command())
32}
33
34fn command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
35    literal("domain")
36        .executes(|ctx: &SteelCommandContext<CommandSource>| {
37            let Some(player) = ctx.source().player() else {
38                return Err(CommandSyntaxError::dynamic(
39                    "you cannot use this command from the console",
40                ));
41            };
42            let server = Arc::clone(ctx.source().server());
43            let player = Arc::clone(player);
44            let menu_player = Arc::clone(&player);
45
46            player.open_menu("Domains", move |context| {
47                domain_menu(context.container_id, menu_player, context.world, &server)
48            });
49
50            Ok(1)
51        })
52        .then(
53            argument("world", SteelArgumentType::world())
54                .executes(switch_world)
55                .then(
56                    argument("location", SteelArgumentType::vec3(true))
57                        .executes(switch_world_to_location)
58                        .then(
59                            argument("rotation", SteelArgumentType::rotation())
60                                .executes(switch_world_to_location_with_rotation),
61                        ),
62                )
63                .then(
64                    argument("rotation", SteelArgumentType::rotation())
65                        .executes(switch_world_with_rotation),
66                ),
67        )
68}
69
70fn switch_world(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
71    switch_world_to(context, WorldEntry::Usual)
72}
73
74fn switch_world_to_location(
75    context: &SteelCommandContext<CommandSource>,
76) -> Result<i32, CommandSyntaxError> {
77    switch_world_at_location(context, None)
78}
79
80fn switch_world_to_location_with_rotation(
81    context: &SteelCommandContext<CommandSource>,
82) -> Result<i32, CommandSyntaxError> {
83    let rotation = context.coordinates("rotation")?.rotation(context.source());
84    switch_world_at_location(context, Some(rotation))
85}
86
87/// Enters the world as usual, then faces the given rotation.
88fn switch_world_with_rotation(
89    context: &SteelCommandContext<CommandSource>,
90) -> Result<i32, CommandSyntaxError> {
91    let player = command_player(context.source())?;
92    let rotation = context.coordinates("rotation")?.rotation(context.source());
93    let rotation = finite_rotation(rotation, player.rotation());
94    switch_world_to(context, WorldEntry::Rotation(wrap_rotation(rotation)))
95}
96
97/// Resolves the location like `/teleport`, keeping the player's own rotation if none is given.
98fn switch_world_at_location(
99    context: &SteelCommandContext<CommandSource>,
100    rotation: Option<(f32, f32)>,
101) -> Result<i32, CommandSyntaxError> {
102    let source = context.source();
103    let player = command_player(source)?;
104    let position = context.coordinates("location")?.position(source);
105    ensure_spawnable_position(position)?;
106    let destination = PreparedSpawn {
107        position,
108        rotation: wrap_rotation(rotation.map_or_else(
109            || player.rotation(),
110            |rotation| finite_rotation(rotation, player.rotation()),
111        )),
112    };
113    switch_world_to(context, WorldEntry::Exact(destination))
114}
115
116fn command_player(source: &CommandSource) -> Result<&Arc<Player>, CommandSyntaxError> {
117    source
118        .player()
119        .ok_or_else(|| CommandSyntaxError::dynamic("This command can only be used by a player"))
120}
121
122fn switch_world_to(
123    context: &SteelCommandContext<CommandSource>,
124    entry: WorldEntry,
125) -> Result<i32, CommandSyntaxError> {
126    let source = context.source();
127    let player = command_player(source)?;
128    let world = context.world_argument("world")?;
129    let world = world.resolve(source)?;
130    source
131        .server()
132        .queue_player_world_selection(Arc::clone(player), &world, entry)
133        .map_err(CommandSyntaxError::dynamic)?;
134
135    let message = match entry {
136        WorldEntry::Exact(PreparedSpawn { position, .. }) => format!(
137            "Switching to world {} at {:.2} {:.2} {:.2}",
138            world.key, position.x, position.y, position.z
139        ),
140        WorldEntry::Rotation((yaw, pitch)) => format!(
141            "Switching to world {} facing {yaw:.1} {pitch:.1}",
142            world.key
143        ),
144        WorldEntry::Usual => format!("Switching to world {}", world.key),
145    };
146    source.send_success(&TextComponent::plain(message), true);
147    Ok(1)
148}
149
150fn domain_menu(
151    container_id: u8,
152    player: Arc<Player>,
153    current_world: &Arc<World>,
154    server: &Arc<Server>,
155) -> Menu {
156    let mut b = MenuBuilder::new(&vanilla_menu_types::GENERIC_9X6, container_id);
157
158    let domain_names: Vec<String> = server
159        .worlds
160        .domain_names()
161        .map(ToOwned::to_owned)
162        .collect();
163
164    let map: Vec<(Section, Vec<Arc<World>>)> = b.grid(6, |g| {
165        g.paint_all(ItemStack::empty());
166        g.paint(
167            Rect::cols(..).rows(0),
168            &vanilla_items::GRAY_STAINED_GLASS_PANE,
169        );
170        g.paint(
171            Rect::cols(..).rows(5),
172            &vanilla_items::GRAY_STAINED_GLASS_PANE,
173        );
174        g.paint(
175            Rect::cols(8).rows(..),
176            &vanilla_items::GRAY_STAINED_GLASS_PANE,
177        );
178        g.paint(
179            Rect::cols(0).rows(1..5),
180            &vanilla_items::GRAY_STAINED_GLASS_PANE,
181        );
182
183        // TODO: Add pagination for domains and worlds instead of truncating to the grid capacity.
184        domain_names
185            .iter()
186            .take(4)
187            .enumerate()
188            .map(|(i, domain_name)| {
189                g.subgrid(Rect::cols(..8).rows(i + 1), |g| {
190                    g.paint_all(ItemStack::empty());
191
192                    let mut sign = ItemStack::new(&vanilla_items::OAK_SIGN);
193                    sign.set(CUSTOM_NAME, domain_name.clone().into());
194
195                    g.paint(Rect::cell(0, 0), sign);
196
197                    let worlds = server.worlds.worlds_in_domain(domain_name);
198
199                    let icons: Vec<ItemStack> =
200                        worlds.iter().map(|w| icon(w, current_world)).collect();
201
202                    let len = icons.len();
203
204                    let container = SimpleContainer::from_items(icons).into_shared();
205
206                    (
207                        g.place(Rect::cols(1..(len + 1).min(6)).rows(0), container)
208                            .display()
209                            .section(),
210                        worlds,
211                    )
212                })
213            })
214            .collect()
215    });
216    b.player_inventory(&player.inventory);
217
218    b.build(DomainMenuKind {
219        map,
220        server: Arc::clone(server),
221        player,
222    })
223}
224
225fn icon(world: &Arc<World>, current_world: &Arc<World>) -> ItemStack {
226    let item = match world.dimension_type {
227        b if b == &vanilla_dimension_types::OVERWORLD
228            || b == &vanilla_dimension_types::OVERWORLD_CAVES =>
229        {
230            &vanilla_items::GRASS_BLOCK
231        }
232        b if b == &vanilla_dimension_types::THE_NETHER => &vanilla_items::NETHERRACK,
233        b if b == &vanilla_dimension_types::THE_END => &vanilla_items::END_STONE,
234        _ => &vanilla_items::BEDROCK,
235    };
236    let mut icon = ItemStack::new(item);
237    icon.set(CUSTOM_NAME, world.key.path.to_string().into());
238    if world.key == current_world.key {
239        icon.set(ENCHANTMENT_GLINT_OVERRIDE, true);
240    }
241    icon
242}
243
244struct DomainMenuKind {
245    map: Vec<(Section, Vec<Arc<World>>)>,
246    server: Arc<Server>,
247    player: Arc<Player>,
248}
249
250// SAFETY: This Steel-owned key uniquely identifies the concrete menu kind
251// within the process.
252unsafe impl steel_utils::DowncastType for DomainMenuKind {
253    const TYPE_KEY: steel_utils::DowncastTypeKey =
254        steel_utils::DowncastTypeKey::new("steel:menu/domain");
255}
256
257impl MenuKind for DomainMenuKind {
258    fn on_slot_clicked(
259        &mut self,
260        _behavior: &mut MenuBehavior,
261        _guard: &mut ContainerLockGuard,
262        click: Click,
263        _player: &Player,
264    ) -> ClickOutcome {
265        let Some(index) = click.slot() else {
266            return ClickOutcome::Fallthrough;
267        };
268
269        let Some((section, worlds)) = self
270            .map
271            .iter()
272            .find(|(section, _worlds)| section.contains(index))
273        else {
274            return ClickOutcome::Fallthrough;
275        };
276
277        if worlds.is_empty() {
278            return ClickOutcome::Consume;
279        }
280
281        let Some(world) = worlds.get(index - section.start()) else {
282            return ClickOutcome::Consume;
283        };
284
285        if !matches!(click, Click::Pickup { .. }) {
286            return ClickOutcome::Consume;
287        }
288
289        if let Err(error) = self.server.queue_player_world_selection(
290            Arc::clone(&self.player),
291            world,
292            WorldEntry::Usual,
293        ) {
294            tracing::debug!(%error, target_world = %world.key, "domain menu selection was rejected");
295        }
296        self.player.close_container();
297        ClickOutcome::Consume
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::super::create_dispatcher;
304    use crate::command::{
305        brigadier::{CommandDispatcher, NodeId},
306        execution::{CommandSource, SteelArgumentType, SteelCommandRuntime},
307    };
308    use steel_registry::init_vanilla_registry;
309
310    type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
311
312    fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
313        let Some(children) = dispatcher.children(parent) else {
314            panic!("parent node should exist");
315        };
316        let Some(child) = children.iter().copied().find(|child| {
317            dispatcher
318                .node(*child)
319                .is_some_and(|node| node.name() == name)
320        }) else {
321            panic!("child {name} should exist");
322        };
323        child
324    }
325
326    #[test]
327    fn domain_graph_uses_the_loaded_world_argument() {
328        init_vanilla_registry();
329        let Ok(dispatcher) = create_dispatcher() else {
330            panic!("built-in commands should register");
331        };
332        let root = child(&dispatcher, dispatcher.root(), "domain");
333        let world = child(&dispatcher, root, "world");
334        assert_eq!(
335            dispatcher.node(world).and_then(|node| node.argument_type()),
336            Some(&SteelArgumentType::world())
337        );
338        let Some(world) = dispatcher.node(world) else {
339            panic!("world argument should exist");
340        };
341        assert!(world.is_executable());
342    }
343}