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")
236}
237
238fn player_count_result(players: &[Arc<Player>]) -> Result<i32, CommandSyntaxError> {
239    i32::try_from(players.len()).map_err(|_| {
240        CommandSyntaxError::dynamic("Target player count exceeds the command result range")
241    })
242}
243
244#[derive(Clone, Copy)]
245enum ExperienceType {
246    Points,
247    Levels,
248}
249
250#[derive(Clone, Copy)]
251enum Mutation {
252    Add,
253    Set,
254}
255
256#[cfg(test)]
257mod tests {
258    use steel_registry::init_vanilla_registry;
259
260    use super::super::create_dispatcher;
261    use super::*;
262    use crate::command::{
263        brigadier::{CommandDispatcher, NodeId},
264        execution::SteelArgumentType,
265    };
266
267    type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
268
269    fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
270        let Some(children) = dispatcher.children(parent) else {
271            panic!("parent node should exist");
272        };
273        let Some(child) = children.iter().copied().find(|child| {
274            dispatcher
275                .node(*child)
276                .is_some_and(|node| node.name() == name)
277        }) else {
278            panic!("child `{name}` should exist");
279        };
280        child
281    }
282
283    #[test]
284    #[expect(
285        clippy::redundant_closure_for_method_calls,
286        reason = "the private CommandNode type cannot be named from this module"
287    )]
288    #[expect(
289        clippy::too_many_lines,
290        reason = "one table-shaped test keeps both command aliases on the same graph contract"
291    )]
292    fn experience_and_xp_roots_share_the_expected_graph() {
293        init_vanilla_registry();
294        let Ok(dispatcher) = create_dispatcher() else {
295            panic!("built-in commands should register");
296        };
297
298        for root_name in ["experience", "xp"] {
299            let root = child(&dispatcher, dispatcher.root(), root_name);
300            assert!(
301                dispatcher
302                    .node(root)
303                    .is_some_and(|node| node.is_restricted())
304            );
305
306            let add = child(&dispatcher, root, "add");
307            let add_target = child(&dispatcher, add, "target");
308            assert_eq!(
309                dispatcher
310                    .node(add_target)
311                    .and_then(|node| node.argument_type()),
312                Some(&SteelArgumentType::players())
313            );
314            let add_amount = child(&dispatcher, add_target, "amount");
315            assert_eq!(
316                dispatcher
317                    .node(add_amount)
318                    .and_then(|node| node.argument_type()),
319                Some(&SteelArgumentType::from(ArgumentType::integer(
320                    i32::MIN,
321                    i32::MAX
322                )))
323            );
324            assert!(
325                dispatcher
326                    .node(add_amount)
327                    .is_some_and(|node| node.is_executable())
328            );
329            for suffix in ["points", "levels"] {
330                let suffix = child(&dispatcher, add_amount, suffix);
331                assert!(
332                    dispatcher
333                        .node(suffix)
334                        .is_some_and(|node| node.is_executable())
335                );
336            }
337
338            let set = child(&dispatcher, root, "set");
339            let set_target = child(&dispatcher, set, "target");
340            assert_eq!(
341                dispatcher
342                    .node(set_target)
343                    .and_then(|node| node.argument_type()),
344                Some(&SteelArgumentType::players())
345            );
346            let set_amount = child(&dispatcher, set_target, "amount");
347            assert_eq!(
348                dispatcher
349                    .node(set_amount)
350                    .and_then(|node| node.argument_type()),
351                Some(&SteelArgumentType::from(ArgumentType::integer(0, i32::MAX)))
352            );
353            assert!(
354                dispatcher
355                    .node(set_amount)
356                    .is_some_and(|node| node.is_executable())
357            );
358            for suffix in ["points", "levels"] {
359                let suffix = child(&dispatcher, set_amount, suffix);
360                assert!(
361                    dispatcher
362                        .node(suffix)
363                        .is_some_and(|node| node.is_executable())
364                );
365            }
366
367            let query = child(&dispatcher, root, "query");
368            let query_target = child(&dispatcher, query, "target");
369            assert_eq!(
370                dispatcher
371                    .node(query_target)
372                    .and_then(|node| node.argument_type()),
373                Some(&SteelArgumentType::player())
374            );
375            for suffix in ["points", "levels"] {
376                let suffix = child(&dispatcher, query_target, suffix);
377                assert!(
378                    dispatcher
379                        .node(suffix)
380                        .is_some_and(|node| node.is_executable())
381                );
382            }
383
384            let clear = child(&dispatcher, root, "clear");
385            assert!(
386                dispatcher
387                    .node(clear)
388                    .is_some_and(|node| node.is_executable())
389            );
390            let clear_target = child(&dispatcher, clear, "target");
391            assert_eq!(
392                dispatcher
393                    .node(clear_target)
394                    .and_then(|node| node.argument_type()),
395                Some(&SteelArgumentType::players())
396            );
397            assert!(
398                dispatcher
399                    .node(clear_target)
400                    .is_some_and(|node| node.is_executable())
401            );
402        }
403    }
404}