Skip to main content

steel_core/command/builtins/
playsound.rs

1//! Vanilla sound playback command.
2
3use 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}