Skip to main content

steel_core/command/builtins/
experience.rs

1//! Vanilla experience command plus Steel's existing clear extension.
2
3use std::sync::Arc;
4
5use steel_utils::{Identifier, translations};
6use text_components::TextComponent;
7
8use super::super::{
9    brigadier::{ArgumentType, CommandNodeBuilder, CommandSyntaxError},
10    execution::{
11        CommandSource, SteelArgumentType, SteelCommandContext, SteelCommandRuntime, argument,
12        literal,
13    },
14    registration::CommandRegistration,
15};
16use crate::entity::Entity;
17use crate::player::Player;
18
19pub(super) fn registration() -> CommandRegistration<CommandSource> {
20    CommandRegistration::new(Identifier::vanilla_static("experience"), |_| command()).alias("xp")
21}
22
23fn command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
24    literal("experience")
25        .then(experience_operation(
26            "add",
27            i32::MIN,
28            add_points,
29            add_levels,
30        ))
31        .then(experience_operation("set", 0, set_points, set_levels))
32        .then(
33            literal("query").then(
34                argument("target", SteelArgumentType::player())
35                    .then(literal("points").executes(query_points))
36                    .then(literal("levels").executes(query_levels)),
37            ),
38        )
39        .then(
40            literal("clear")
41                .executes(clear_source)
42                .then(argument("target", SteelArgumentType::players()).executes(clear_targets)),
43        )
44}
45
46fn experience_operation(
47    name: &'static str,
48    minimum: i32,
49    points: fn(&SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError>,
50    levels: fn(&SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError>,
51) -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
52    literal(name).then(
53        argument("target", SteelArgumentType::players()).then(
54            argument("amount", ArgumentType::integer(minimum, i32::MAX))
55                .executes(points)
56                .then(literal("points").executes(points))
57                .then(literal("levels").executes(levels)),
58        ),
59    )
60}
61
62fn query_points(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
63    query_experience(context, ExperienceType::Points)
64}
65
66fn query_levels(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
67    query_experience(context, ExperienceType::Levels)
68}
69
70fn query_experience(
71    context: &SteelCommandContext<CommandSource>,
72    experience_type: ExperienceType,
73) -> Result<i32, CommandSyntaxError> {
74    let player = context.player("target")?;
75    let amount = {
76        let experience = player.experience.lock();
77        match experience_type {
78            ExperienceType::Points => experience.points(),
79            ExperienceType::Levels => experience.level(),
80        }
81    };
82    let translation = match experience_type {
83        ExperienceType::Points => &translations::COMMANDS_EXPERIENCE_QUERY_POINTS,
84        ExperienceType::Levels => &translations::COMMANDS_EXPERIENCE_QUERY_LEVELS,
85    };
86    let message = translation
87        .message([
88            TextComponent::plain(player.plain_text_name()),
89            TextComponent::from(amount.to_string()),
90        ])
91        .component();
92    context.source().send_success(&message, false);
93    Ok(amount)
94}
95
96fn add_points(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
97    add_experience(context, ExperienceType::Points)
98}
99
100fn add_levels(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
101    add_experience(context, ExperienceType::Levels)
102}
103
104fn add_experience(
105    context: &SteelCommandContext<CommandSource>,
106    experience_type: ExperienceType,
107) -> Result<i32, CommandSyntaxError> {
108    let players = context.players("target")?;
109    let amount = required_amount(context)?;
110    for player in &players {
111        match experience_type {
112            ExperienceType::Points => player.give_experience_points(amount),
113            ExperienceType::Levels => player.give_experience_levels(amount),
114        }
115    }
116
117    send_mutation_success(context, &players, amount, experience_type, Mutation::Add);
118    player_count_result(&players)
119}
120
121fn set_points(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
122    set_experience(context, ExperienceType::Points)
123}
124
125fn set_levels(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
126    set_experience(context, ExperienceType::Levels)
127}
128
129fn set_experience(
130    context: &SteelCommandContext<CommandSource>,
131    experience_type: ExperienceType,
132) -> Result<i32, CommandSyntaxError> {
133    let players = context.players("target")?;
134    let amount = required_amount(context)?;
135    let mut success = 0usize;
136    for player in &players {
137        let changed = match experience_type {
138            ExperienceType::Points => {
139                let mut experience = player.experience.lock();
140                if experience.can_set_points(amount) {
141                    experience.set_points(amount);
142                    true
143                } else {
144                    false
145                }
146            }
147            ExperienceType::Levels => {
148                player.experience.lock().set_levels(amount);
149                true
150            }
151        };
152        success += usize::from(changed);
153    }
154
155    if success == 0 {
156        return Err(CommandSyntaxError::dynamic(TextComponent::from(
157            &translations::COMMANDS_EXPERIENCE_SET_POINTS_INVALID,
158        )));
159    }
160
161    send_mutation_success(context, &players, amount, experience_type, Mutation::Set);
162    player_count_result(&players)
163}
164
165fn send_mutation_success(
166    context: &SteelCommandContext<CommandSource>,
167    players: &[Arc<Player>],
168    amount: i32,
169    experience_type: ExperienceType,
170    mutation: Mutation,
171) {
172    let amount = TextComponent::from(amount.to_string());
173    let message = if let [player] = players {
174        let translation = match (mutation, experience_type) {
175            (Mutation::Add, ExperienceType::Points) => {
176                &translations::COMMANDS_EXPERIENCE_ADD_POINTS_SUCCESS_SINGLE
177            }
178            (Mutation::Add, ExperienceType::Levels) => {
179                &translations::COMMANDS_EXPERIENCE_ADD_LEVELS_SUCCESS_SINGLE
180            }
181            (Mutation::Set, ExperienceType::Points) => {
182                &translations::COMMANDS_EXPERIENCE_SET_POINTS_SUCCESS_SINGLE
183            }
184            (Mutation::Set, ExperienceType::Levels) => {
185                &translations::COMMANDS_EXPERIENCE_SET_LEVELS_SUCCESS_SINGLE
186            }
187        };
188        translation
189            .message([amount, TextComponent::plain(player.plain_text_name())])
190            .component()
191    } else {
192        let translation = match (mutation, experience_type) {
193            (Mutation::Add, ExperienceType::Points) => {
194                &translations::COMMANDS_EXPERIENCE_ADD_POINTS_SUCCESS_MULTIPLE
195            }
196            (Mutation::Add, ExperienceType::Levels) => {
197                &translations::COMMANDS_EXPERIENCE_ADD_LEVELS_SUCCESS_MULTIPLE
198            }
199            (Mutation::Set, ExperienceType::Points) => {
200                &translations::COMMANDS_EXPERIENCE_SET_POINTS_SUCCESS_MULTIPLE
201            }
202            (Mutation::Set, ExperienceType::Levels) => {
203                &translations::COMMANDS_EXPERIENCE_SET_LEVELS_SUCCESS_MULTIPLE
204            }
205        };
206        translation
207            .message([amount, TextComponent::from(players.len().to_string())])
208            .component()
209    };
210    context.source().send_success(&message, true);
211}
212
213#[expect(
214    clippy::unnecessary_wraps,
215    reason = "Command executors use a shared fallible callback signature."
216)]
217fn clear_source(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
218    if let Some(player) = context.source().player() {
219        player.experience.lock().clear();
220    }
221    Ok(1)
222}
223
224fn clear_targets(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
225    let players = context.players("target")?;
226    for player in &players {
227        player.experience.lock().clear();
228    }
229    player_count_result(&players)
230}
231
232fn required_amount(
233    context: &SteelCommandContext<CommandSource>,
234) -> Result<i32, CommandSyntaxError> {
235    context.integer("amount").ok_or_else(|| {
236        CommandSyntaxError::dynamic("Parsed experience amount is missing from the command context")
237    })
238}
239
240fn player_count_result(players: &[Arc<Player>]) -> Result<i32, CommandSyntaxError> {
241    i32::try_from(players.len()).map_err(|_| {
242        CommandSyntaxError::dynamic("Target player count exceeds the command result range")
243    })
244}
245
246#[derive(Clone, Copy)]
247enum ExperienceType {
248    Points,
249    Levels,
250}
251
252#[derive(Clone, Copy)]
253enum Mutation {
254    Add,
255    Set,
256}
257
258#[cfg(test)]
259mod tests {
260    use steel_registry::init_vanilla_registry;
261
262    use super::super::create_dispatcher;
263    use super::*;
264    use crate::command::{
265        brigadier::{CommandDispatcher, NodeId},
266        execution::SteelArgumentType,
267    };
268
269    type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
270
271    fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
272        let Some(children) = dispatcher.children(parent) else {
273            panic!("parent node should exist");
274        };
275        let Some(child) = children.iter().copied().find(|child| {
276            dispatcher
277                .node(*child)
278                .is_some_and(|node| node.name() == name)
279        }) else {
280            panic!("child `{name}` should exist");
281        };
282        child
283    }
284
285    #[test]
286    #[expect(
287        clippy::redundant_closure_for_method_calls,
288        reason = "the private CommandNode type cannot be named from this module"
289    )]
290    #[expect(
291        clippy::too_many_lines,
292        reason = "one table-shaped test keeps both command aliases on the same graph contract"
293    )]
294    fn experience_and_xp_roots_share_the_expected_graph() {
295        init_vanilla_registry();
296        let Ok(dispatcher) = create_dispatcher() else {
297            panic!("built-in commands should register");
298        };
299
300        for root_name in ["experience", "xp"] {
301            let root = child(&dispatcher, dispatcher.root(), root_name);
302            assert!(
303                dispatcher
304                    .node(root)
305                    .is_some_and(|node| node.is_restricted())
306            );
307
308            let add = child(&dispatcher, root, "add");
309            let add_target = child(&dispatcher, add, "target");
310            assert_eq!(
311                dispatcher
312                    .node(add_target)
313                    .and_then(|node| node.argument_type()),
314                Some(&SteelArgumentType::players())
315            );
316            let add_amount = child(&dispatcher, add_target, "amount");
317            assert_eq!(
318                dispatcher
319                    .node(add_amount)
320                    .and_then(|node| node.argument_type()),
321                Some(&SteelArgumentType::from(ArgumentType::integer(
322                    i32::MIN,
323                    i32::MAX
324                )))
325            );
326            assert!(
327                dispatcher
328                    .node(add_amount)
329                    .is_some_and(|node| node.is_executable())
330            );
331            for suffix in ["points", "levels"] {
332                let suffix = child(&dispatcher, add_amount, suffix);
333                assert!(
334                    dispatcher
335                        .node(suffix)
336                        .is_some_and(|node| node.is_executable())
337                );
338            }
339
340            let set = child(&dispatcher, root, "set");
341            let set_target = child(&dispatcher, set, "target");
342            assert_eq!(
343                dispatcher
344                    .node(set_target)
345                    .and_then(|node| node.argument_type()),
346                Some(&SteelArgumentType::players())
347            );
348            let set_amount = child(&dispatcher, set_target, "amount");
349            assert_eq!(
350                dispatcher
351                    .node(set_amount)
352                    .and_then(|node| node.argument_type()),
353                Some(&SteelArgumentType::from(ArgumentType::integer(0, i32::MAX)))
354            );
355            assert!(
356                dispatcher
357                    .node(set_amount)
358                    .is_some_and(|node| node.is_executable())
359            );
360            for suffix in ["points", "levels"] {
361                let suffix = child(&dispatcher, set_amount, suffix);
362                assert!(
363                    dispatcher
364                        .node(suffix)
365                        .is_some_and(|node| node.is_executable())
366                );
367            }
368
369            let query = child(&dispatcher, root, "query");
370            let query_target = child(&dispatcher, query, "target");
371            assert_eq!(
372                dispatcher
373                    .node(query_target)
374                    .and_then(|node| node.argument_type()),
375                Some(&SteelArgumentType::player())
376            );
377            for suffix in ["points", "levels"] {
378                let suffix = child(&dispatcher, query_target, suffix);
379                assert!(
380                    dispatcher
381                        .node(suffix)
382                        .is_some_and(|node| node.is_executable())
383                );
384            }
385
386            let clear = child(&dispatcher, root, "clear");
387            assert!(
388                dispatcher
389                    .node(clear)
390                    .is_some_and(|node| node.is_executable())
391            );
392            let clear_target = child(&dispatcher, clear, "target");
393            assert_eq!(
394                dispatcher
395                    .node(clear_target)
396                    .and_then(|node| node.argument_type()),
397                Some(&SteelArgumentType::players())
398            );
399            assert!(
400                dispatcher
401                    .node(clear_target)
402                    .is_some_and(|node| node.is_executable())
403            );
404        }
405    }
406}