diff --git a/crates/pumpkin-util/src/text/legacy.rs b/crates/pumpkin-util/src/text/legacy.rs index a2a6002ae..2a0181380 100644 --- a/crates/pumpkin-util/src/text/legacy.rs +++ b/crates/pumpkin-util/src/text/legacy.rs @@ -237,6 +237,11 @@ impl TextComponent { TextContent::Custom { key, with, .. } => { text.push_str(&get_translation_text(key.clone(), locale, with.clone())); } + TextContent::PlayerSprite { profile, .. } => { + if let Some(name) = profile.0.get_string("name") { + text.push_str(name); + } + } } // 4. Recursively append extra components diff --git a/crates/pumpkin-util/src/text/mod.rs b/crates/pumpkin-util/src/text/mod.rs index 7597db73f..29445b598 100644 --- a/crates/pumpkin-util/src/text/mod.rs +++ b/crates/pumpkin-util/src/text/mod.rs @@ -139,6 +139,20 @@ impl TextComponentBase { compound.put_list("with", list); } } + TextContent::PlayerSprite { + type_name, + profile, + hat, + } => { + let full_type = if type_name.contains(':') { + type_name.to_string() + } else { + format!("minecraft:{type_name}") + }; + compound.put_string("type", full_type); + compound.put_compound("player", profile.0.clone()); + compound.put_byte("hat", i8::from(*hat)); + } } if let Some(ref color) = self.style.color { @@ -300,6 +314,10 @@ impl TextComponentBase { } => selector.into_owned(), TextContent::Keybind { keybind } => keybind.into_owned(), TextContent::Custom { key, with, .. } => translation_to_pretty(key, Locale::EnUs, with), + TextContent::PlayerSprite { ref profile, .. } => profile + .0 + .get_string("name") + .map_or_else(|| "player_sprite".to_string(), ToString::to_string), }; let style = self.style; let color = style.color; @@ -352,6 +370,11 @@ impl TextComponentBase { TextContent::Custom { key, .. } => { let _ = write!(text, "%{key}"); } + TextContent::PlayerSprite { profile, .. } => { + if let Some(name) = profile.0.get_string("name") { + text.push_str(name); + } + } } for child in &self.extra { @@ -412,6 +435,11 @@ impl TextComponentBase { TextContent::Custom { key, with, .. } => { text.push_str(&get_translation_text(key.clone(), locale, with.clone())); } + TextContent::PlayerSprite { profile, .. } => { + if let Some(name) = profile.0.get_string("name") { + text.push_str(name); + } + } } // 3. Recursively append extra components @@ -450,6 +478,11 @@ impl TextComponentBase { } => selector.into_owned(), TextContent::Keybind { keybind } => keybind.into_owned(), TextContent::Custom { key, with, .. } => get_translation_text(key, locale, with), + TextContent::PlayerSprite { profile, .. } => profile + .0 + .get_string("name") + .map(ToString::to_string) + .unwrap_or_default(), }; // Recursively append the text of all child components @@ -800,6 +833,20 @@ impl TextComponent { } impl TextComponent { + /// Creates a player sprite component. + #[must_use] + pub fn player_sprite(profile: pumpkin_nbt::NbtCompound, hat: bool) -> Self { + Self(TextComponentBase { + content: Box::new(TextContent::PlayerSprite { + type_name: Cow::Borrowed("minecraft:player_sprite"), + profile: ProfileNbt(profile), + hat, + }), + style: Box::default(), + extra: vec![], + }) + } + /// Encodes this component into a byte array using NBT serialization. /// /// # Returns @@ -1158,6 +1205,17 @@ impl TextComponent { } } +#[derive(Clone, Debug, PartialEq)] +pub struct ProfileNbt(pub pumpkin_nbt::NbtCompound); + +impl Eq for ProfileNbt {} + +impl std::hash::Hash for ProfileNbt { + fn hash(&self, state: &mut H) { + self.0.to_string().hash(state); + } +} + /// The content type of the text component. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(untagged)] @@ -1203,6 +1261,13 @@ pub enum TextContent { /// Substitution parameters for the translation. with: Vec, }, + /// A player sprite object component. + #[serde(skip)] + PlayerSprite { + type_name: Cow<'static, str>, + profile: ProfileNbt, + hat: bool, + }, } /// Tests for the text component implementations. diff --git a/crates/pumpkin/src/command/commands/fetchprofile.rs b/crates/pumpkin/src/command/commands/fetchprofile.rs new file mode 100644 index 000000000..fcbd70713 --- /dev/null +++ b/crates/pumpkin/src/command/commands/fetchprofile.rs @@ -0,0 +1,387 @@ +use crate::command::argument_builder::{ArgumentBuilder, argument, command, literal}; +use crate::command::argument_types::core::string::StringArgumentType; +use crate::command::argument_types::entity::EntityArgumentType; +use crate::command::argument_types::uuid::UuidArgumentType; +use crate::command::context::command_context::CommandContext; +use crate::command::context::command_source::CommandSource; +use crate::command::errors::error_types::CommandErrorType; +use crate::command::node::dispatcher::CommandDispatcher; +use crate::command::node::{CommandExecutor, CommandExecutorResult}; +use crate::entity::EntityBase; +use crate::net::authentication::{fetch_profile_by_uuid, lookup_profile_by_name}; +use crate::net::{GameProfile, offline_uuid}; +use crate::server::Server; +use pumpkin_data::translation; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_nbt::tag::NbtTag; +use pumpkin_util::PermissionLvl; +use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry}; +use pumpkin_util::text::click::ClickEvent; +use pumpkin_util::text::{TextComponent, color::NamedColor}; +use std::borrow::Cow; +use std::sync::Arc; +use uuid::Uuid; + +const DESCRIPTION: &str = "Fetches a player's profile."; +const PERMISSION: &str = "minecraft:command.fetchprofile"; + +const ARG_NAME: &str = "name"; +const ARG_ID: &str = "id"; +const ARG_ENTITY: &str = "entity"; + +pub const NO_PROFILE_ERROR_TYPE: CommandErrorType<1> = CommandErrorType::new( + translation::java::COMMANDS_FETCHPROFILE_NO_PROFILE, + translation::java::COMMANDS_FETCHPROFILE_NO_PROFILE, +); + +const fn uuid_to_int_array(uuid: &Uuid) -> [i32; 4] { + let (most, least) = uuid.as_u64_pair(); + [ + (most >> 32) as i32, + most as i32, + (least >> 32) as i32, + least as i32, + ] +} + +fn game_profile_to_nbt(profile: &GameProfile) -> NbtCompound { + let mut compound = NbtCompound::new(); + let int_array = uuid_to_int_array(&profile.id); + compound.put("id", NbtTag::IntArray(int_array.to_vec())); + if !profile.name.is_empty() { + compound.put_string("name", profile.name.clone()); + } + let properties = profile.properties.load(); + if !properties.is_empty() { + let mut prop_list = Vec::new(); + for prop in properties.iter() { + let mut prop_compound = NbtCompound::new(); + prop_compound.put_string("name", prop.name.to_string()); + prop_compound.put_string("value", prop.value.to_string()); + if let Some(ref sig) = prop.signature { + prop_compound.put_string("signature", sig.to_string()); + } + prop_list.push(NbtTag::Compound(prop_compound)); + } + compound.put_list("properties", prop_list); + } + compound +} + +fn format_clickable_list(items: Vec) -> TextComponent { + let mut root = TextComponent::empty(); + for (i, item) in items.into_iter().enumerate() { + if i > 0 { + root = root.add_child(TextComponent::text(" ")); + } + let styled = item.color_named(NamedColor::Green); + let wrapped = styled.wrap_in_square_brackets(); + root = root.add_child(wrapped); + } + root +} + +async fn report_resolved_profile( + source: &CommandSource, + profile: &GameProfile, + message_id: &'static str, + argument: TextComponent, +) { + let encoded_profile_compound = game_profile_to_nbt(profile); + let encoded_profile_as_string = encoded_profile_compound.to_string(); + + let head_component = TextComponent::player_sprite(encoded_profile_compound, true); + let encoded_component_as_string = head_component + .0 + .clone() + .to_translated() + .to_nbt_compound() + .to_string(); + + let clickable = format_clickable_list(vec![ + TextComponent::translate_cross( + translation::java::COMMANDS_FETCHPROFILE_COPY_COMPONENT, + translation::java::COMMANDS_FETCHPROFILE_COPY_COMPONENT, + [], + ) + .click_event(ClickEvent::CopyToClipboard { + value: Cow::from(encoded_profile_as_string.clone()), + }), + TextComponent::translate_cross( + translation::java::COMMANDS_FETCHPROFILE_GIVE_ITEM, + translation::java::COMMANDS_FETCHPROFILE_GIVE_ITEM, + [], + ) + .click_event(ClickEvent::RunCommand { + command: Cow::from(format!( + "give @s minecraft:player_head[profile={encoded_profile_as_string}]" + )), + }), + TextComponent::translate_cross( + translation::java::COMMANDS_FETCHPROFILE_SUMMON_MANNEQUIN, + translation::java::COMMANDS_FETCHPROFILE_SUMMON_MANNEQUIN, + [], + ) + .click_event(ClickEvent::RunCommand { + command: Cow::from(format!( + "summon minecraft:mannequin ~ ~ ~ {{profile:{encoded_profile_as_string}}}" + )), + }), + TextComponent::translate_cross( + translation::java::COMMANDS_FETCHPROFILE_COPY_TEXT, + translation::java::COMMANDS_FETCHPROFILE_COPY_TEXT, + [head_component.color_named(NamedColor::White)], + ) + .click_event(ClickEvent::CopyToClipboard { + value: Cow::from(encoded_component_as_string), + }), + ]); + + let msg = TextComponent::translate_cross(message_id, message_id, [argument, clickable]); + + source.send_feedback(msg, false).await; +} + +async fn fetch_profile_by_name_helper(server: &Server, name: &str) -> Option { + if let Some(player) = server.get_player_by_name(name) { + return Some(player.gameprofile.clone()); + } + + let cached_entry = server.data.user_cache.write().await.get_by_name(name); + + let auth_config = server + .advanced_config + .networking + .java + .authentication + .clone(); + let name_string = name.to_string(); + + let mojang_res = + tokio::task::spawn_blocking(move || lookup_profile_by_name(&name_string, &auth_config)) + .await + .ok() + .and_then(Result::ok) + .flatten(); + + if let Some((uuid, resolved_name)) = mojang_res { + server + .data + .user_cache + .write() + .await + .upsert(uuid, resolved_name.clone()); + let auth_config_clone = server + .advanced_config + .networking + .java + .authentication + .clone(); + let full_profile = + tokio::task::spawn_blocking(move || fetch_profile_by_uuid(uuid, &auth_config_clone)) + .await + .ok() + .and_then(Result::ok) + .flatten(); + + return Some(full_profile.unwrap_or_else(|| GameProfile { + id: uuid, + name: resolved_name, + properties: arc_swap::ArcSwap::new(Arc::new(vec![])), + profile_actions: None, + })); + } + + if let Some(entry) = cached_entry { + return Some(GameProfile { + id: entry.uuid, + name: entry.name, + properties: arc_swap::ArcSwap::new(Arc::new(vec![])), + profile_actions: None, + }); + } + + if !server.advanced_config.networking.java.online_mode + && let Ok(uuid) = offline_uuid(name) + { + let profile = GameProfile { + id: uuid, + name: name.to_string(), + properties: arc_swap::ArcSwap::new(Arc::new(vec![])), + profile_actions: None, + }; + server + .data + .user_cache + .write() + .await + .upsert(uuid, name.to_string()); + return Some(profile); + } + + None +} + +async fn fetch_profile_by_id_helper(server: &Server, id: Uuid) -> Option { + if let Some(player) = server.get_player_by_uuid(id) { + return Some(player.gameprofile.clone()); + } + + let auth_config = server + .advanced_config + .networking + .java + .authentication + .clone(); + let mojang_res = tokio::task::spawn_blocking(move || fetch_profile_by_uuid(id, &auth_config)) + .await + .ok() + .and_then(Result::ok) + .flatten(); + + if let Some(profile) = mojang_res { + server + .data + .user_cache + .write() + .await + .upsert(profile.id, profile.name.clone()); + return Some(profile); + } + + let cached_entry = server.data.user_cache.write().await.get_by_uuid(id); + if let Some(entry) = cached_entry { + return Some(GameProfile { + id: entry.uuid, + name: entry.name, + properties: arc_swap::ArcSwap::new(Arc::new(vec![])), + profile_actions: None, + }); + } + + None +} + +struct ResolveNameExecutor; + +impl CommandExecutor for ResolveNameExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let name = StringArgumentType::get(context, ARG_NAME)?; + let server = context.server().clone(); + let source = context.source.clone(); + let name_owned = name.to_string(); + + tokio::spawn(async move { + let name_component = TextComponent::text(name_owned.clone()); + let result = fetch_profile_by_name_helper(&server, &name_owned).await; + match result { + Some(profile) => { + report_resolved_profile( + &source, + &profile, + translation::java::COMMANDS_FETCHPROFILE_NAME_SUCCESS, + name_component, + ) + .await; + } + None => { + source + .send_error(TextComponent::translate_cross( + translation::java::COMMANDS_FETCHPROFILE_NAME_FAILURE, + translation::java::COMMANDS_FETCHPROFILE_NAME_FAILURE, + [name_component], + )) + .await; + } + } + }); + + Ok(1) + }) + } +} + +struct ResolveIdExecutor; + +impl CommandExecutor for ResolveIdExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let id = UuidArgumentType::get(context, ARG_ID)?; + let server = context.server().clone(); + let source = context.source.clone(); + + tokio::spawn(async move { + let id_component = TextComponent::text(id.to_string()); + let result = fetch_profile_by_id_helper(&server, id).await; + match result { + Some(profile) => { + report_resolved_profile( + &source, + &profile, + translation::java::COMMANDS_FETCHPROFILE_ID_SUCCESS, + id_component, + ) + .await; + } + None => { + source + .send_error(TextComponent::translate_cross( + translation::java::COMMANDS_FETCHPROFILE_ID_FAILURE, + translation::java::COMMANDS_FETCHPROFILE_ID_FAILURE, + [id_component], + )) + .await; + } + } + }); + + Ok(1) + }) + } +} + +struct PrintForEntityExecutor; + +impl CommandExecutor for PrintForEntityExecutor { + fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> { + Box::pin(async move { + let entity = EntityArgumentType::get_entity(context, ARG_ENTITY).await?; + + if let Some(player) = entity.get_player() { + report_resolved_profile( + &context.source, + &player.gameprofile, + translation::java::COMMANDS_FETCHPROFILE_ENTITY_SUCCESS, + player.get_display_name().await, + ) + .await; + Ok(1) + } else { + Err(NO_PROFILE_ERROR_TYPE.create_without_context(entity.get_display_name().await)) + } + }) + } +} + +pub fn register(dispatcher: &mut CommandDispatcher, registry: &mut PermissionRegistry) { + registry.register_permission_or_panic(Permission::new( + PERMISSION, + DESCRIPTION, + PermissionDefault::Op(PermissionLvl::Two), + )); + + dispatcher.register( + command("fetchprofile", DESCRIPTION) + .requires(PERMISSION) + .then(literal("name").then( + argument(ARG_NAME, StringArgumentType::GreedyPhrase).executes(ResolveNameExecutor), + )) + .then( + literal("id").then(argument(ARG_ID, UuidArgumentType).executes(ResolveIdExecutor)), + ) + .then(literal("entity").then( + argument(ARG_ENTITY, EntityArgumentType::Entity).executes(PrintForEntityExecutor), + )), + ); +} diff --git a/crates/pumpkin/src/command/commands/mod.rs b/crates/pumpkin/src/command/commands/mod.rs index dfe7d1e5f..d2297112e 100644 --- a/crates/pumpkin/src/command/commands/mod.rs +++ b/crates/pumpkin/src/command/commands/mod.rs @@ -24,6 +24,7 @@ mod effect; mod enchant; mod execute; mod experience; +mod fetchprofile; mod fill; mod fillbiome; mod forceload; @@ -204,6 +205,7 @@ pub async fn default_dispatcher( teammsg::register(&mut dispatcher, registry); clone::register(&mut dispatcher, registry); attribute::register(&mut dispatcher, registry); + fetchprofile::register(&mut dispatcher, registry); dispatcher } diff --git a/crates/pumpkin/src/command/commands/pumpkin.rs b/crates/pumpkin/src/command/commands/pumpkin.rs index c804d4a1b..6dd37a19a 100644 --- a/crates/pumpkin/src/command/commands/pumpkin.rs +++ b/crates/pumpkin/src/command/commands/pumpkin.rs @@ -5,25 +5,69 @@ use pumpkin_util::text::{TextComponent, color::NamedColor}; use pumpkin_util::translation::get_translation_text; use serde::Deserialize; use std::borrow::Cow; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; use crate::command::CommandResult; use crate::command::{CommandExecutor, CommandSender, args::ConsumedArgs, tree::CommandTree}; -const NAMES: [&str; 2] = ["pumpkin", "version"]; +const NAMES: [&str; 3] = ["pumpkin", "version", "ver"]; const DESCRIPTION: &str = "Display information about Pumpkin."; +const CACHE_DURATION: Duration = Duration::from_hours(24); + struct Executor; const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); const GIT_HASH: &str = env!("GIT_HASH"); const GIT_HASH_FULL: &str = env!("GIT_HASH_FULL"); -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] struct Contributor { login: String, } +struct ContributorCache { + fetched_at: Instant, + data: Vec, +} + +static CONTRIBUTORS_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + +struct DonatorCache { + fetched_at: Instant, + data: TextComponent, +} + +static DONATORS_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +fn fetch_all_contributors_cached() -> Vec { + if let Ok(guard) = CONTRIBUTORS_CACHE.lock() + && let Some(cache) = guard.as_ref() + && cache.fetched_at.elapsed() < CACHE_DURATION + { + return cache.data.clone(); + } + + let contributors = fetch_all_contributors(); + if !contributors.is_empty() { + if let Ok(mut guard) = CONTRIBUTORS_CACHE.lock() { + *guard = Some(ContributorCache { + fetched_at: Instant::now(), + data: contributors.clone(), + }); + } + } else if let Ok(guard) = CONTRIBUTORS_CACHE.lock() + && let Some(cache) = guard.as_ref() + { + return cache.data.clone(); + } + + contributors +} + fn fetch_all_contributors() -> Vec { let mut all_contributors = Vec::new(); let mut next_url = Some( @@ -173,6 +217,25 @@ fn fetch_donators_hover() -> TextComponent { donators_text.add_child(TextComponent::text("Unable to load donators")) } +fn fetch_donators_hover_cached() -> TextComponent { + if let Ok(guard) = DONATORS_CACHE.lock() + && let Some(cache) = guard.as_ref() + && cache.fetched_at.elapsed() < CACHE_DURATION + { + return cache.data.clone(); + } + + let donators = fetch_donators_hover(); + if let Ok(mut guard) = DONATORS_CACHE.lock() { + *guard = Some(DonatorCache { + fetched_at: Instant::now(), + data: donators.clone(), + }); + } + + donators +} + #[expect(clippy::too_many_lines)] impl CommandExecutor for Executor { fn execute<'a>( @@ -182,7 +245,7 @@ impl CommandExecutor for Executor { _args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let contributors = tokio::task::spawn_blocking(fetch_all_contributors) + let contributors = tokio::task::spawn_blocking(fetch_all_contributors_cached) .await .unwrap_or_default(); let contributor_names = contributors @@ -284,7 +347,7 @@ impl CommandExecutor for Executor { msg = msg.add_child(TextComponent::text(" ")); - let donators_hover = tokio::task::spawn_blocking(fetch_donators_hover) + let donators_hover = tokio::task::spawn_blocking(fetch_donators_hover_cached) .await .unwrap_or_else(|_| TextComponent::text("Unable to load donators")); msg = msg.add_child( @@ -329,3 +392,43 @@ impl CommandExecutor for Executor { pub fn init_command_tree() -> CommandTree { CommandTree::new(NAMES, DESCRIPTION).execute(Executor) } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn cache_duration_is_24_hours() { + assert_eq!(CACHE_DURATION, Duration::from_hours(24)); + } + + #[test] + fn contributor_cache_updates_and_retrieves() { + let mut guard = CONTRIBUTORS_CACHE.lock().unwrap(); + *guard = Some(ContributorCache { + fetched_at: Instant::now(), + data: vec![Contributor { + login: "test_user".to_string(), + }], + }); + drop(guard); + + let contributors = fetch_all_contributors_cached(); + assert_eq!(contributors.len(), 1); + assert_eq!(contributors[0].login, "test_user"); + } + + #[test] + fn donator_cache_updates_and_retrieves() { + let expected = TextComponent::text("Cached Donator Test"); + let mut guard = DONATORS_CACHE.lock().unwrap(); + *guard = Some(DonatorCache { + fetched_at: Instant::now(), + data: expected.clone(), + }); + drop(guard); + + let cached = fetch_donators_hover_cached(); + assert_eq!(cached, expected); + } +} diff --git a/crates/pumpkin/src/command/dispatcher.rs b/crates/pumpkin/src/command/dispatcher.rs index 12ba012e9..c6a20e0f0 100644 --- a/crates/pumpkin/src/command/dispatcher.rs +++ b/crates/pumpkin/src/command/dispatcher.rs @@ -735,6 +735,22 @@ mod test { dispatcher.register(tree, "minecraft:test"); } + #[tokio::test] + async fn pumpkin_command_aliases() { + let config = BasicConfiguration::default(); + let registry = RwLock::new(PermissionRegistry::new()); + let dispatcher = default_dispatcher(®istry, &config) + .await + .fallback_dispatcher; + + let pumpkin_tree = dispatcher.get_tree("pumpkin").unwrap(); + let version_tree = dispatcher.get_tree("version").unwrap(); + let ver_tree = dispatcher.get_tree("ver").unwrap(); + + assert_eq!(pumpkin_tree.description, version_tree.description); + assert_eq!(pumpkin_tree.description, ver_tree.description); + } + #[test] fn syntax_renderer_outputs_two_messages_with_context_styling() { let input = "0123456789abcdefghij"; diff --git a/crates/pumpkin/src/net/authentication.rs b/crates/pumpkin/src/net/authentication.rs index 6da13d48f..1000b4625 100644 --- a/crates/pumpkin/src/net/authentication.rs +++ b/crates/pumpkin/src/net/authentication.rs @@ -214,6 +214,33 @@ pub fn lookup_profile_by_name( Ok(Some((parsed_uuid, profile.name))) } +pub fn fetch_profile_by_uuid( + uuid: Uuid, + _auth_config: &AuthenticationConfig, +) -> Result, AuthError> { + let url = format!( + "https://sessionserver.mojang.com/session/minecraft/profile/{}?unsigned=false", + uuid.simple() + ); + + let mut response = ureq::get(&url) + .call() + .map_err(|_| AuthError::FailedResponse)?; + + match response.status() { + StatusCode::OK => {} + StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => return Ok(None), + other => Err(AuthError::UnknownStatusCode(other))?, + } + + let profile: GameProfile = response + .body_mut() + .read_json() + .map_err(|_| AuthError::FailedParse)?; + + Ok(Some(profile)) +} + #[derive(Error, Debug)] pub enum AuthError { #[error("Authentication servers are down")]