steel_core/command/builtins/
experience.rs1use 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}