diff --git a/pumpkin-protocol/src/client/play/c_set_experience.rs b/pumpkin-protocol/src/client/play/c_set_experience.rs new file mode 100644 index 00000000..0e565a56 --- /dev/null +++ b/pumpkin-protocol/src/client/play/c_set_experience.rs @@ -0,0 +1,23 @@ +use pumpkin_data::packet::clientbound::PLAY_SET_EXPERIENCE; +use pumpkin_macros::client_packet; +use serde::Serialize; + +use crate::VarInt; + +#[derive(Serialize)] +#[client_packet(PLAY_SET_EXPERIENCE)] +pub struct CSetExperience { + progress: f32, + level: VarInt, + total_experience: VarInt, +} + +impl CSetExperience { + pub fn new(progress: f32, level: VarInt, total_experience: VarInt) -> Self { + Self { + progress, + level, + total_experience, + } + } +} diff --git a/pumpkin-protocol/src/client/play/mod.rs b/pumpkin-protocol/src/client/play/mod.rs index c166a587..ffbd3c30 100644 --- a/pumpkin-protocol/src/client/play/mod.rs +++ b/pumpkin-protocol/src/client/play/mod.rs @@ -50,6 +50,7 @@ mod c_set_border_warning_distance; mod c_set_container_content; mod c_set_container_property; mod c_set_container_slot; +mod c_set_experience; mod c_set_health; mod c_set_held_item; mod c_set_time; @@ -122,6 +123,7 @@ pub use c_set_border_warning_distance::*; pub use c_set_container_content::*; pub use c_set_container_property::*; pub use c_set_container_slot::*; +pub use c_set_experience::*; pub use c_set_health::*; pub use c_set_held_item::*; pub use c_set_time::*; diff --git a/pumpkin-util/src/math/experience.rs b/pumpkin-util/src/math/experience.rs new file mode 100644 index 00000000..c6b1a3c8 --- /dev/null +++ b/pumpkin-util/src/math/experience.rs @@ -0,0 +1,39 @@ +/// Get the number of points in a level +pub fn points_in_level(level: i32) -> i32 { + match level { + 0..=15 => 2 * level + 7, + 16..=30 => 5 * level - 38, + _ => 9 * level - 158, + } +} + +/// Calculate the total number of points to reach a level +pub fn points_to_level(level: i32) -> i32 { + match level { + 0..=15 => (level * level + 6 * level) / 2, + 16..=30 => ((2.5 * f64::from(level * level)) - (40.5 * f64::from(level)) + 360.0) as i32, + _ => ((4.5 * f64::from(level * level)) - (162.5 * f64::from(level)) + 2220.0) as i32, + } +} + +/// Calculate level and points from total points +pub fn total_to_level_and_points(total_points: i32) -> (i32, i32) { + let level = match total_points { + 0..=352 => ((total_points as f64 + 9.0).sqrt() - 3.0) as i32, + 353..=1507 => (81.0 + (total_points as f64 - 7839.0) / 40.0).sqrt() as i32, + _ => (325.0 + (total_points as f64 - 54215.0) / 72.0).sqrt() as i32, + }; + + let level_start = points_to_level(level); + let points_into_level = total_points - level_start; + + (level, points_into_level) +} + +/// Calculate progress (0.0 to 1.0) from points within a level +pub fn progress_in_level(points: i32, level: i32) -> f32 { + let max_points = points_in_level(level); + #[allow(clippy::cast_precision_loss)] + let progress = (points as f32) / (max_points as f32); + progress.clamp(0.0, 1.0) +} diff --git a/pumpkin-util/src/math/mod.rs b/pumpkin-util/src/math/mod.rs index 6f49757f..345fbd8f 100644 --- a/pumpkin-util/src/math/mod.rs +++ b/pumpkin-util/src/math/mod.rs @@ -1,6 +1,7 @@ use num_traits::PrimInt; pub mod boundingbox; +pub mod experience; pub mod position; pub mod vector2; pub mod vector3; diff --git a/pumpkin/src/command/commands/experience.rs b/pumpkin/src/command/commands/experience.rs new file mode 100644 index 00000000..6b623bfb --- /dev/null +++ b/pumpkin/src/command/commands/experience.rs @@ -0,0 +1,341 @@ +use std::sync::atomic::Ordering; + +use async_trait::async_trait; +use pumpkin_util::math::experience; +use pumpkin_util::text::color::{Color, NamedColor}; +use pumpkin_util::text::TextComponent; + +use crate::command::args::bounded_num::BoundedNumArgumentConsumer; +use crate::command::args::players::PlayersArgumentConsumer; +use crate::command::args::{ConsumedArgs, FindArg}; +use crate::command::tree::CommandTree; +use crate::command::tree_builder::{argument, literal}; +use crate::command::{CommandError, CommandExecutor, CommandSender}; +use crate::entity::player::Player; + +const NAMES: [&str; 2] = ["experience", "xp"]; +const DESCRIPTION: &str = "Add, set or query player experience."; +const ARG_TARGETS: &str = "targets"; +const ARG_AMOUNT: &str = "amount"; + +fn xp_amount() -> BoundedNumArgumentConsumer { + BoundedNumArgumentConsumer::new() + .name(ARG_AMOUNT) + .min(0) + .max(i32::MAX) +} + +#[derive(Clone, Copy, PartialEq)] +enum Mode { + Add, + Set, + Query, +} + +#[derive(Clone, Copy, PartialEq)] +enum ExpType { + Points, + Levels, +} + +struct ExperienceExecutor { + mode: Mode, + exp_type: Option, +} + +impl ExperienceExecutor { + async fn handle_query( + &self, + sender: &mut CommandSender<'_>, + target: &Player, + exp_type: ExpType, + ) { + match exp_type { + ExpType::Levels => { + let level = target.experience_level.load(Ordering::Relaxed); + sender + .send_message(TextComponent::translate( + "commands.experience.query.levels", + [ + TextComponent::text(target.gameprofile.name.clone()), + TextComponent::text(level.to_string()), + ] + .into(), + )) + .await; + } + ExpType::Points => { + let points = target.experience_points.load(Ordering::Relaxed); + sender + .send_message(TextComponent::translate( + "commands.experience.query.points", + [ + TextComponent::text(target.gameprofile.name.clone()), + TextComponent::text(points.to_string()), + ] + .into(), + )) + .await; + } + } + } + + fn get_success_message( + mode: Mode, + exp_type: ExpType, + amount: i32, + targets_len: usize, + target_name: Option, + ) -> TextComponent { + match (mode, exp_type) { + (Mode::Add, ExpType::Points) => { + if targets_len > 1 { + TextComponent::translate( + "commands.experience.add.points.success.multiple", + [ + TextComponent::text(amount.to_string()), + TextComponent::text(targets_len.to_string()), + ] + .into(), + ) + } else { + TextComponent::translate( + "commands.experience.add.points.success.single", + [ + TextComponent::text(amount.to_string()), + TextComponent::text(target_name.unwrap()), + ] + .into(), + ) + } + } + (Mode::Add, ExpType::Levels) => { + if targets_len > 1 { + TextComponent::translate( + "commands.experience.add.levels.success.multiple", + [ + TextComponent::text(amount.to_string()), + TextComponent::text(targets_len.to_string()), + ] + .into(), + ) + } else { + TextComponent::translate( + "commands.experience.add.levels.success.single", + [ + TextComponent::text(amount.to_string()), + TextComponent::text(target_name.unwrap()), + ] + .into(), + ) + } + } + (Mode::Set, ExpType::Points) => { + if targets_len > 1 { + TextComponent::translate( + "commands.experience.set.points.success.multiple", + [ + TextComponent::text(amount.to_string()), + TextComponent::text(targets_len.to_string()), + ] + .into(), + ) + } else { + TextComponent::translate( + "commands.experience.set.points.success.single", + [ + TextComponent::text(amount.to_string()), + TextComponent::text(target_name.unwrap()), + ] + .into(), + ) + } + } + (Mode::Set, ExpType::Levels) => { + if targets_len > 1 { + TextComponent::translate( + "commands.experience.set.levels.success.multiple", + [ + TextComponent::text(amount.to_string()), + TextComponent::text(targets_len.to_string()), + ] + .into(), + ) + } else { + TextComponent::translate( + "commands.experience.set.levels.success.single", + [ + TextComponent::text(amount.to_string()), + TextComponent::text(target_name.unwrap()), + ] + .into(), + ) + } + } + (Mode::Query, _) => unreachable!("Query mode doesn't use success messages"), + } + } + + async fn handle_modify( + &self, + target: &Player, + amount: i32, + exp_type: ExpType, + mode: Mode, + ) -> Result<(), &'static str> { + match exp_type { + ExpType::Levels => { + if mode == Mode::Add { + target.add_experience_levels(amount).await; + } else { + target.set_experience_level(amount, true).await; + } + } + ExpType::Points => { + if mode == Mode::Add { + target.add_experience_points(amount).await; + } else { + // target.set_experience_points(amount).await; This could + let current_level = target.experience_level.load(Ordering::Relaxed); + let current_max_points = experience::points_in_level(current_level); + + if amount > current_max_points { + return Err("commands.experience.set.points.invalid"); + } + + target.set_experience_points(amount).await; + } + } + } + Ok(()) + } +} + +#[async_trait] +impl CommandExecutor for ExperienceExecutor { + async fn execute<'a>( + &self, + sender: &mut CommandSender<'a>, + _server: &crate::server::Server, + args: &ConsumedArgs<'a>, + ) -> Result<(), CommandError> { + let targets = PlayersArgumentConsumer::find_arg(args, ARG_TARGETS)?; + + match self.mode { + Mode::Query => { + if targets.len() != 1 { + // TODO: Add proper error message for multiple players in query mode + return Ok(()); + } + self.handle_query(sender, &targets[0], self.exp_type.unwrap()) + .await; + } + Mode::Add | Mode::Set => { + let Ok(amount) = BoundedNumArgumentConsumer::::find_arg(args, ARG_AMOUNT)? + else { + sender + .send_message(TextComponent::translate( + "commands.experience.set.points.invalid", + [].into(), + )) + .await; + return Ok(()); + }; + + if self.mode == Mode::Set && amount < 0 { + sender + .send_message(TextComponent::translate( + "commands.experience.set.points.invalid", + [].into(), + )) + .await; + return Ok(()); + } + + for target in targets { + match self + .handle_modify(target, amount, self.exp_type.unwrap(), self.mode) + .await + { + Ok(()) => { + let msg = Self::get_success_message( + self.mode, + self.exp_type.unwrap(), + amount, + targets.len(), + Some(target.gameprofile.name.clone()), + ); + sender.send_message(msg).await; + } + Err(error_msg) => { + sender + .send_message( + TextComponent::translate(error_msg, [].into()) + .color(Color::Named(NamedColor::Red)), + ) + .await; + continue; + } + } + } + } + } + + Ok(()) + } +} + +pub fn init_command_tree() -> CommandTree { + CommandTree::new(NAMES, DESCRIPTION) + .then( + literal("add").then( + argument(ARG_TARGETS, PlayersArgumentConsumer).then( + argument(ARG_AMOUNT, xp_amount()) + .then(literal("levels").execute(ExperienceExecutor { + mode: Mode::Add, + exp_type: Some(ExpType::Levels), + })) + .then(literal("points").execute(ExperienceExecutor { + mode: Mode::Add, + exp_type: Some(ExpType::Points), + })) + .execute(ExperienceExecutor { + mode: Mode::Add, + exp_type: Some(ExpType::Points), + }), + ), + ), + ) + .then( + literal("set").then( + argument(ARG_TARGETS, PlayersArgumentConsumer).then( + argument(ARG_AMOUNT, xp_amount()) + .then(literal("levels").execute(ExperienceExecutor { + mode: Mode::Set, + exp_type: Some(ExpType::Levels), + })) + .then(literal("points").execute(ExperienceExecutor { + mode: Mode::Set, + exp_type: Some(ExpType::Points), + })) + .execute(ExperienceExecutor { + mode: Mode::Set, + exp_type: Some(ExpType::Points), + }), + ), + ), + ) + .then( + literal("query").then( + argument(ARG_TARGETS, PlayersArgumentConsumer) + .then(literal("levels").execute(ExperienceExecutor { + mode: Mode::Query, + exp_type: Some(ExpType::Levels), + })) + .then(literal("points").execute(ExperienceExecutor { + mode: Mode::Query, + exp_type: Some(ExpType::Points), + })), + ), + ) +} diff --git a/pumpkin/src/command/commands/mod.rs b/pumpkin/src/command/commands/mod.rs index 77f9ec21..0c1cf6e4 100644 --- a/pumpkin/src/command/commands/mod.rs +++ b/pumpkin/src/command/commands/mod.rs @@ -4,6 +4,7 @@ pub mod banlist; pub mod bossbar; pub mod clear; pub mod deop; +pub mod experience; pub mod fill; pub mod gamemode; pub mod give; diff --git a/pumpkin/src/command/mod.rs b/pumpkin/src/command/mod.rs index 0ac970db..a9714feb 100644 --- a/pumpkin/src/command/mod.rs +++ b/pumpkin/src/command/mod.rs @@ -10,9 +10,9 @@ use crate::world::World; use args::ConsumedArgs; use async_trait::async_trait; use commands::{ - ban, banip, banlist, clear, deop, fill, gamemode, give, help, kick, kill, list, me, msg, op, - pardon, pardonip, playsound, plugin, plugins, pumpkin, say, setblock, stop, summon, teleport, - time, title, worldborder, + ban, banip, banlist, clear, deop, experience, fill, gamemode, give, help, kick, kill, list, me, + msg, op, pardon, pardonip, playsound, plugin, plugins, pumpkin, say, setblock, stop, summon, + teleport, time, title, worldborder, }; use dispatcher::CommandError; use pumpkin_util::math::vector3::Vector3; @@ -144,6 +144,7 @@ pub fn default_dispatcher() -> CommandDispatcher { dispatcher.register(banlist::init_command_tree(), PermissionLvl::Three); dispatcher.register(pardon::init_command_tree(), PermissionLvl::Three); dispatcher.register(pardonip::init_command_tree(), PermissionLvl::Three); + dispatcher.register(experience::init_command_tree(), PermissionLvl::Two); dispatcher } diff --git a/pumpkin/src/entity/player.rs b/pumpkin/src/entity/player.rs index 688ea11a..2373fbff 100644 --- a/pumpkin/src/entity/player.rs +++ b/pumpkin/src/entity/player.rs @@ -21,8 +21,8 @@ use pumpkin_protocol::{ client::play::{ CActionBar, CCombatDeath, CDisguisedChatMessage, CEntityStatus, CGameEvent, CHurtAnimation, CKeepAlive, CPlayDisconnect, CPlayerAbilities, CPlayerInfoUpdate, CPlayerPosition, - CSetHealth, CSubtitle, CSystemChatMessage, CTitleText, GameEvent, MetaDataType, - PlayerAction, + CSetExperience, CSetHealth, CSubtitle, CSystemChatMessage, CTitleText, GameEvent, + MetaDataType, PlayerAction, }, server::play::{ SChatCommand, SChatMessage, SClientCommand, SClientInformationPlay, SClientTickEnd, @@ -47,6 +47,7 @@ use pumpkin_protocol::{ use pumpkin_util::{ math::{ boundingbox::{BoundingBox, BoundingBoxSize}, + experience, position::BlockPos, vector2::Vector2, vector3::Vector3, @@ -136,6 +137,12 @@ pub struct Player { pub client_loaded: AtomicBool, /// timeout (in ticks) client has to report it has finished loading. pub client_loaded_timeout: AtomicU32, + /// The player's experience level + pub experience_level: AtomicI32, + /// The player's experience progress (0.0 to 1.0) + pub experience_progress: AtomicCell, + /// The player's total experience points + pub experience_points: AtomicI32, } impl Player { @@ -217,6 +224,9 @@ impl Player { |op| AtomicCell::new(op.level), ), inventory: Mutex::new(PlayerInventory::new()), + experience_level: AtomicI32::new(0), + experience_progress: AtomicCell::new(0.0), + experience_points: AtomicI32::new(0), } } @@ -738,7 +748,86 @@ impl Player { .send_packet(&CSystemChatMessage::new(text, overlay)) .await; } + + /// Sets the player's experience level and updates the client + #[allow(clippy::cast_precision_loss)] + pub async fn set_experience(&self, level: i32, progress: f32, points: i32) { + self.experience_level.store(level, Ordering::Relaxed); + self.experience_progress.store(progress.clamp(0.0, 1.0)); + self.experience_points.store(points, Ordering::Relaxed); + + self.client + .send_packet(&CSetExperience::new( + progress.clamp(0.0, 1.0), + level.into(), + points.into(), + )) + .await; + } + + /// Sets the player's experience level directly + #[allow(clippy::cast_precision_loss)] + pub async fn set_experience_level(&self, new_level: i32, keep_progress: bool) { + let progress = self.experience_progress.load(); + let mut points = self.experience_points.load(Ordering::Relaxed); + + // If keep progress is true then calculate the number of points needed to keep the same progress scaled + if keep_progress { + // Get our current level + let current_level = self.experience_level.load(Ordering::Relaxed); + let current_max_points = experience::points_in_level(current_level); + // Calculate the max value for new level + let new_max_points = experience::points_in_level(new_level); + // Calculate the scaling factor + let scale = new_max_points as f32 / current_max_points as f32; + // Scale the points (Vanilla doesn't seem to recalculate progress so we won't) + points = (points as f32 * scale) as i32; + } + + self.set_experience(new_level, progress, points).await; + } + + /// Add experience levels to the player + pub async fn add_experience_levels(&self, added_levels: i32) { + let current_level = self.experience_level.load(Ordering::Relaxed); + let new_level = current_level + added_levels; + self.set_experience_level(new_level, true).await; + } + + /// Set the player's experience points directly, Returns true if successful. + #[allow(clippy::cast_precision_loss)] + pub async fn set_experience_points(&self, new_points: i32) -> bool { + let current_points = self.experience_points.load(Ordering::Relaxed); + + if new_points == current_points { + return true; + } + + let current_level = self.experience_level.load(Ordering::Relaxed); + let max_points = experience::points_in_level(current_level); + + if new_points < 0 || new_points > max_points { + return false; + } + + let progress = new_points as f32 / max_points as f32; + self.set_experience(current_level, progress, new_points) + .await; + true + } + + /// Add experience points to the player + pub async fn add_experience_points(&self, added_points: i32) { + let current_level = self.experience_level.load(Ordering::Relaxed); + let current_points = self.experience_points.load(Ordering::Relaxed); + let total_exp = experience::points_to_level(current_level) + current_points; + let new_total_exp = total_exp + added_points; + let (new_level, new_points) = experience::total_to_level_and_points(new_total_exp); + let progress = experience::progress_in_level(new_level, new_points); + self.set_experience(new_level, progress, new_points).await; + } } + #[async_trait] impl NBTStorage for Player { async fn write_nbt(&self, nbt: &mut NbtCompound) { @@ -748,12 +837,25 @@ impl NBTStorage for Player { self.inventory.lock().await.selected as i32, ); self.abilities.lock().await.write_nbt(nbt).await; + + // Store total XP instead of individual components + let total_exp = experience::points_to_level(self.experience_level.load(Ordering::Relaxed)) + + self.experience_points.load(Ordering::Relaxed); + nbt.put_int("XpTotal", total_exp); } async fn read_nbt(&mut self, nbt: &mut NbtCompound) { self.living_entity.read_nbt(nbt).await; self.inventory.lock().await.selected = nbt.get_int("SelectedItemSlot").unwrap_or(0) as u32; self.abilities.lock().await.read_nbt(nbt).await; + + // Load from total XP + let total_exp = nbt.get_int("XpTotal").unwrap_or(0); + let (level, points) = experience::total_to_level_and_points(total_exp); + let progress = experience::progress_in_level(level, points); + self.experience_level.store(level, Ordering::Relaxed); + self.experience_progress.store(progress); + self.experience_points.store(points, Ordering::Relaxed); } }