steel_core/command/builtins/
playsound.rs1use std::sync::Arc;
4
5use steel_protocol::packets::game::SoundSource;
6use steel_registry::sound_event::SoundEventHolder;
7use steel_utils::{Identifier, translations};
8use text_components::TextComponent;
9
10use super::super::{
11 brigadier::{ArgumentType, CommandNodeBuilder, CommandSyntaxError},
12 execution::{
13 CommandSource, SteelArgumentType, SteelCommandContext, SteelCommandRuntime, argument,
14 literal,
15 },
16 registration::CommandRegistration,
17};
18use crate::{entity::Entity as _, player::Player};
19
20pub(super) fn registration() -> CommandRegistration<CommandSource> {
21 CommandRegistration::new(Identifier::vanilla_static("playsound"), |_| command())
22}
23
24fn command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
25 let mut sound = argument("sound", SteelArgumentType::sound())
26 .executes(|context| execute_as_source(context, SoundSource::Master));
27 for source in SoundSource::VALUES {
28 sound = sound.then(source_command(source));
29 }
30 literal("playsound").then(sound)
31}
32
33fn source_command(source: SoundSource) -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
34 let min_volume = argument("minVolume", ArgumentType::float(0.0, 1.0))
35 .executes(move |context| execute_for_targets(context, source));
36 let pitch = argument("pitch", ArgumentType::float(0.0, 2.0))
37 .executes(move |context| execute_for_targets(context, source))
38 .then(min_volume);
39 let volume = argument("volume", ArgumentType::float(0.0, f32::MAX))
40 .executes(move |context| execute_for_targets(context, source))
41 .then(pitch);
42 let pos = argument("pos", SteelArgumentType::vec3(true))
43 .executes(move |context| execute_for_targets(context, source))
44 .then(volume);
45 let targets = argument("targets", SteelArgumentType::players())
46 .executes(move |context| execute_for_targets(context, source))
47 .then(pos);
48
49 literal(source.name())
50 .executes(move |context| execute_as_source(context, source))
51 .then(targets)
52}
53
54fn execute_as_source(
55 context: &SteelCommandContext<CommandSource>,
56 source: SoundSource,
57) -> Result<i32, CommandSyntaxError> {
58 let targets = context
59 .source()
60 .player()
61 .map_or_else(Vec::new, |player| vec![Arc::clone(player)]);
62 execute(context, source, &targets)
63}
64
65fn execute_for_targets(
66 context: &SteelCommandContext<CommandSource>,
67 source: SoundSource,
68) -> Result<i32, CommandSyntaxError> {
69 let targets = context.players("targets")?;
70 execute(context, source, &targets)
71}
72
73fn execute(
74 context: &SteelCommandContext<CommandSource>,
75 source: SoundSource,
76 targets: &[Arc<Player>],
77) -> Result<i32, CommandSyntaxError> {
78 let sound_id = context.identifier("sound")?.clone();
79 let sound = SoundEventHolder::Direct {
80 sound_id: sound_id.clone(),
81 fixed_range: None,
82 };
83 let position = match context.coordinates("pos") {
84 Ok(coordinates) => coordinates.position(context.source()),
85 Err(_) => context.source().position(),
86 };
87 let volume = context.float("volume").unwrap_or(1.0);
88 let pitch = context.float("pitch").unwrap_or(1.0);
89 let min_volume = context.float("minVolume").unwrap_or(0.0);
90
91 let played_for = context
92 .source()
93 .world()
94 .play_sound_to_players(&sound, source, position, volume, pitch, min_volume, targets);
95 if played_for.is_empty() {
96 return Err(CommandSyntaxError::dynamic(TextComponent::from(
97 &translations::COMMANDS_PLAYSOUND_FAILED,
98 )));
99 }
100
101 let message = if let [target] = played_for.as_slice() {
102 translations::COMMANDS_PLAYSOUND_SUCCESS_SINGLE
103 .message([
104 TextComponent::plain(sound_id.to_string()),
105 target.display_name(),
106 ])
107 .component()
108 } else {
109 translations::COMMANDS_PLAYSOUND_SUCCESS_MULTIPLE
110 .message([
111 TextComponent::plain(sound_id.to_string()),
112 TextComponent::plain(played_for.len().to_string()),
113 ])
114 .component()
115 };
116 context.source().send_success(&message, true);
117
118 i32::try_from(played_for.len()).map_err(|_| {
119 CommandSyntaxError::dynamic("Played-for player count exceeds the command result range")
120 })
121}
122
123#[cfg(test)]
124mod tests {
125 use steel_protocol::packets::game::{ArgumentType as ProtocolArgumentType, SuggestionType};
126 use steel_registry::init_vanilla_registry;
127
128 use super::super::create_dispatcher;
129 use crate::command::brigadier::{ArgumentType, CommandDispatcher, NodeId};
130 use crate::command::execution::CommandSource;
131 use crate::command::execution::{SteelArgumentType, SteelCommandRuntime};
132
133 type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
134
135 fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
136 let Some(children) = dispatcher.children(parent) else {
137 panic!("parent node should exist");
138 };
139 let Some(child) = children.iter().copied().find(|child| {
140 dispatcher
141 .node(*child)
142 .is_some_and(|node| node.name() == name)
143 }) else {
144 panic!("child {name} should exist");
145 };
146 child
147 }
148
149 #[expect(
150 clippy::redundant_closure_for_method_calls,
151 reason = "the command node type is intentionally private to the Brigadier module"
152 )]
153 #[test]
154 fn playsound_graph_matches_vanilla_shape() {
155 init_vanilla_registry();
156 let Ok(dispatcher) = create_dispatcher() else {
157 panic!("built-in commands should register");
158 };
159 let playsound = child(&dispatcher, dispatcher.root(), "playsound");
160 let Some(playsound_node) = dispatcher.node(playsound) else {
161 panic!("playsound root should exist");
162 };
163 assert!(playsound_node.is_restricted());
164 assert!(!playsound_node.is_executable());
165
166 let sound = child(&dispatcher, playsound, "sound");
167 let Some(sound_node) = dispatcher.node(sound) else {
168 panic!("sound argument should exist");
169 };
170 assert!(sound_node.is_executable());
171 assert_eq!(
172 sound_node.argument_type(),
173 Some(&SteelArgumentType::sound())
174 );
175 let (protocol_argument, suggestions) = SteelArgumentType::sound().protocol_argument();
176 assert!(matches!(
177 protocol_argument,
178 ProtocolArgumentType::ResourceLocation
179 ));
180 assert!(matches!(suggestions, Some(SuggestionType::AvailableSounds)));
181
182 let source_names = [
183 "master", "music", "record", "weather", "block", "hostile", "neutral", "player",
184 "ambient", "voice", "ui",
185 ];
186 for source_name in source_names {
187 let source = child(&dispatcher, sound, source_name);
188 let Some(source_node) = dispatcher.node(source) else {
189 panic!("sound source should exist");
190 };
191 assert!(source_node.is_executable());
192
193 let targets = child(&dispatcher, source, "targets");
194 assert_eq!(
195 dispatcher
196 .node(targets)
197 .and_then(|node| node.argument_type()),
198 Some(&SteelArgumentType::players())
199 );
200 assert!(
201 dispatcher
202 .node(targets)
203 .is_some_and(|node| node.is_executable())
204 );
205
206 let pos = child(&dispatcher, targets, "pos");
207 assert_eq!(
208 dispatcher.node(pos).and_then(|node| node.argument_type()),
209 Some(&SteelArgumentType::vec3(true))
210 );
211 assert!(
212 dispatcher
213 .node(pos)
214 .is_some_and(|node| node.is_executable())
215 );
216
217 let volume = child(&dispatcher, pos, "volume");
218 assert_eq!(
219 dispatcher
220 .node(volume)
221 .and_then(|node| node.argument_type()),
222 Some(&SteelArgumentType::from(
223 ArgumentType::float(0.0, f32::MAX,)
224 ))
225 );
226 assert!(
227 dispatcher
228 .node(volume)
229 .is_some_and(|node| node.is_executable())
230 );
231
232 let pitch = child(&dispatcher, volume, "pitch");
233 assert_eq!(
234 dispatcher.node(pitch).and_then(|node| node.argument_type()),
235 Some(&SteelArgumentType::from(ArgumentType::float(0.0, 2.0,)))
236 );
237 assert!(
238 dispatcher
239 .node(pitch)
240 .is_some_and(|node| node.is_executable())
241 );
242
243 let min_volume = child(&dispatcher, pitch, "minVolume");
244 assert_eq!(
245 dispatcher
246 .node(min_volume)
247 .and_then(|node| node.argument_type()),
248 Some(&SteelArgumentType::from(ArgumentType::float(0.0, 1.0,)))
249 );
250 assert!(
251 dispatcher
252 .node(min_volume)
253 .is_some_and(|node| node.is_executable())
254 );
255 }
256 }
257}