steel_core/command/builtins/
domain.rs1use 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
87fn 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
97fn 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 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
250unsafe 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}