From 980af02ffce488c545628473ce067319f494d9e6 Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Sat, 1 Mar 2025 05:15:22 +0100 Subject: [PATCH 01/12] feat(player-data): Support saving and loading player NBT Data --- pumpkin-config/src/lib.rs | 3 + pumpkin-config/src/player_data.rs | 22 + pumpkin-nbt/Cargo.toml | 1 + pumpkin-nbt/src/lib.rs | 1 + pumpkin-nbt/src/nbt_compress.rs | 330 ++++++++++++++ pumpkin/src/command/commands/experience.rs | 20 +- pumpkin/src/data/mod.rs | 1 + pumpkin/src/data/player_data.rs | 492 +++++++++++++++++++++ pumpkin/src/entity/hunger.rs | 4 +- pumpkin/src/entity/player.rs | 133 ++++-- pumpkin/src/error.rs | 19 + pumpkin/src/lib.rs | 19 + pumpkin/src/server/mod.rs | 32 +- pumpkin/src/world/mod.rs | 15 +- 14 files changed, 1037 insertions(+), 55 deletions(-) create mode 100644 pumpkin-config/src/player_data.rs create mode 100644 pumpkin-nbt/src/nbt_compress.rs create mode 100644 pumpkin/src/data/player_data.rs diff --git a/pumpkin-config/src/lib.rs b/pumpkin-config/src/lib.rs index 6e3abc54a..58cd12bb6 100644 --- a/pumpkin-config/src/lib.rs +++ b/pumpkin-config/src/lib.rs @@ -28,10 +28,12 @@ mod commands; pub mod chunk; pub mod op; +mod player_data; mod pvp; mod server_links; use networking::NetworkingConfig; +use player_data::PlayerDataConfig; use resource_pack::ResourcePackConfig; const CONFIG_ROOT_FOLDER: &str = "config/"; @@ -92,6 +94,7 @@ pub struct AdvancedConfiguration { pub commands: CommandsConfig, pub pvp: PVPConfig, pub server_links: ServerLinksConfig, + pub player_data: PlayerDataConfig, } #[derive(Serialize, Deserialize)] diff --git a/pumpkin-config/src/player_data.rs b/pumpkin-config/src/player_data.rs new file mode 100644 index 000000000..704c5b70a --- /dev/null +++ b/pumpkin-config/src/player_data.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +#[serde(default)] +pub struct PlayerDataConfig { + /// Is Player Data saving enabled? + pub save_player_data: bool, + /// Is Player Data should be cached? + pub cache_player_data: bool, + /// Maximum amount of players to cache. + pub max_cache_entries: u16, +} + +impl Default for PlayerDataConfig { + fn default() -> Self { + Self { + save_player_data: true, + cache_player_data: true, + max_cache_entries: 256, + } + } +} diff --git a/pumpkin-nbt/Cargo.toml b/pumpkin-nbt/Cargo.toml index d9eaa8c7a..7fcf22a7d 100644 --- a/pumpkin-nbt/Cargo.toml +++ b/pumpkin-nbt/Cargo.toml @@ -9,3 +9,4 @@ thiserror.workspace = true bytes.workspace = true cesu8 = "1.1" +flate2 = "1.1" \ No newline at end of file diff --git a/pumpkin-nbt/src/lib.rs b/pumpkin-nbt/src/lib.rs index cc44a9b7e..7905aa9bc 100644 --- a/pumpkin-nbt/src/lib.rs +++ b/pumpkin-nbt/src/lib.rs @@ -14,6 +14,7 @@ use thiserror::Error; pub mod compound; pub mod deserializer; +pub mod nbt_compress; pub mod serializer; pub mod tag; diff --git a/pumpkin-nbt/src/nbt_compress.rs b/pumpkin-nbt/src/nbt_compress.rs new file mode 100644 index 000000000..5a6ddad7d --- /dev/null +++ b/pumpkin-nbt/src/nbt_compress.rs @@ -0,0 +1,330 @@ +use crate::{Error, Nbt, NbtCompound, deserializer, serializer}; +use flate2::{Compression, read::GzDecoder, write::GzEncoder}; +use std::io::{Read, Write}; + +/// Reads a GZipped NBT compound tag. +/// +/// This function takes a byte slice containing a GZipped NBT compound tag and returns the deserialized compound. +/// +/// # Arguments +/// +/// * `compressed_data` - A byte slice containing the GZipped NBT data +/// +/// # Returns +/// +/// A Result containing either the parsed NbtCompound or an Error +pub fn read_gzip_compound_tag(compressed_data: &[u8]) -> Result { + // Create a GZip decoder + let mut decoder = GzDecoder::new(compressed_data); + + // Decompress the data + let mut decompressed_data = Vec::new(); + decoder + .read_to_end(&mut decompressed_data) + .map_err(Error::Incomplete)?; + + // Read the NBT data + let nbt = Nbt::read(&mut deserializer::ReadAdaptor::new(&decompressed_data[..]))?; + Ok(nbt.root_tag) +} + +/// Writes an NBT compound tag with GZip compression. +/// +/// This function takes an NbtCompound and writes it as a GZipped byte vector. +/// +/// # Arguments +/// +/// * `compound` - The NbtCompound to serialize and compress +/// +/// # Returns +/// +/// A Result containing either the compressed data as a byte vector or an Error +pub fn write_gzip_compound_tag(compound: &NbtCompound) -> Result, Error> { + // First serialize the NBT data + let nbt = Nbt::new(String::new(), compound.clone()); + let serialized = nbt.write(); + + // Then compress it with GZip + let mut compressed_data = Vec::new(); + { + let mut encoder = GzEncoder::new(&mut compressed_data, Compression::default()); + encoder.write_all(&serialized).map_err(Error::Incomplete)?; + encoder.finish().map_err(Error::Incomplete)?; + } + + Ok(compressed_data) +} + +/// Reads a GZipped NBT structure. +/// +/// This function takes a byte slice containing a GZipped NBT structure and deserializes it into a user-provided type. +/// +/// # Arguments +/// +/// * `compressed_data` - A byte slice containing the GZipped NBT data +/// +/// # Returns +/// +/// A Result containing either the parsed structure or an Error +pub fn from_gzip_bytes<'a, T>(compressed_data: &[u8]) -> Result +where + T: serde::Deserialize<'a>, +{ + // Create a GZip decoder + let mut decoder = GzDecoder::new(compressed_data); + + // Decompress the data + let mut decompressed_data = Vec::new(); + decoder + .read_to_end(&mut decompressed_data) + .map_err(Error::Incomplete)?; + + // Deserialize the NBT data + deserializer::from_bytes(&decompressed_data[..]) +} + +/// Writes a GZipped NBT structure. +/// +/// This function takes a serializable structure and writes it as a GZipped byte vector. +/// +/// # Arguments +/// +/// * `value` - The value to serialize and compress +/// +/// # Returns +/// +/// A Result containing either the compressed data as a byte vector or an Error +pub fn to_gzip_bytes(value: &T) -> Result, Error> +where + T: serde::Serialize, +{ + // First serialize the NBT data + let mut uncompressed_data = Vec::new(); + serializer::to_bytes(value, &mut uncompressed_data)?; + + // Then compress it with GZip + let mut compressed_data = Vec::new(); + { + let mut encoder = GzEncoder::new(&mut compressed_data, Compression::default()); + encoder + .write_all(&uncompressed_data) + .map_err(Error::Incomplete)?; + encoder.finish().map_err(Error::Incomplete)?; + } + + Ok(compressed_data) +} + +#[cfg(test)] +mod tests { + use crate::{ + NbtCompound, + nbt_compress::{ + from_gzip_bytes, read_gzip_compound_tag, to_gzip_bytes, write_gzip_compound_tag, + }, + tag::NbtTag, + }; + use serde::{Deserialize, Serialize}; + use std::collections::HashMap; + + #[test] + fn test_gzip_read_write_compound() { + // Create a test compound + let mut compound = NbtCompound::new(); + compound.put_byte("byte_value", 123); + compound.put_short("short_value", 12345); + compound.put_int("int_value", 1234567); + compound.put_long("long_value", 123456789); + compound.put_float("float_value", 123.456); + compound.put_double("double_value", 123456.789); + compound.put_bool("bool_value", true); + compound.put("string_value", NbtTag::String("test string".to_string())); + + // Create a nested compound + let mut nested = NbtCompound::new(); + nested.put_int("nested_int", 42); + compound.put_component("nested_compound", nested); + + // Write to GZip + let compressed = write_gzip_compound_tag(&compound).expect("Failed to compress compound"); + + // Read from GZip + let read_compound = + read_gzip_compound_tag(&compressed).expect("Failed to decompress compound"); + + // Verify values + assert_eq!(read_compound.get_byte("byte_value"), Some(123)); + assert_eq!(read_compound.get_short("short_value"), Some(12345)); + assert_eq!(read_compound.get_int("int_value"), Some(1234567)); + assert_eq!(read_compound.get_long("long_value"), Some(123456789)); + assert_eq!(read_compound.get_float("float_value"), Some(123.456)); + assert_eq!(read_compound.get_double("double_value"), Some(123456.789)); + assert_eq!(read_compound.get_bool("bool_value"), Some(true)); + assert_eq!( + read_compound.get_string("string_value").map(String::as_str), + Some("test string") + ); + + // Verify nested compound + if let Some(nested) = read_compound.get_compound("nested_compound") { + assert_eq!(nested.get_int("nested_int"), Some(42)); + } else { + panic!("Failed to retrieve nested compound"); + } + } + + #[test] + fn test_gzip_empty_compound() { + let compound = NbtCompound::new(); + let compressed = + write_gzip_compound_tag(&compound).expect("Failed to compress empty compound"); + let read_compound = + read_gzip_compound_tag(&compressed).expect("Failed to decompress empty compound"); + + assert_eq!(read_compound.child_tags.len(), 0); + } + + #[test] + fn test_gzip_large_compound() { + let mut compound = NbtCompound::new(); + + // Add 1000 integer entries + for i in 0..1000 { + compound.put_int(&format!("value_{}", i), i); + } + + let compressed = + write_gzip_compound_tag(&compound).expect("Failed to compress large compound"); + let read_compound = + read_gzip_compound_tag(&compressed).expect("Failed to decompress large compound"); + + assert_eq!(read_compound.child_tags.len(), 1000); + + // Verify a few entries + assert_eq!(read_compound.get_int("value_0"), Some(0)); + assert_eq!(read_compound.get_int("value_500"), Some(500)); + assert_eq!(read_compound.get_int("value_999"), Some(999)); + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStruct { + string_field: String, + int_field: i32, + bool_field: bool, + float_field: f32, + string_list: Vec, + nested: NestedStruct, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct NestedStruct { + value: i64, + name: String, + } + + #[test] + fn test_gzip_serialize_deserialize() { + let test_struct = TestStruct { + string_field: "test string".to_string(), + int_field: 12345, + bool_field: true, + float_field: 123.456, + string_list: vec!["one".to_string(), "two".to_string(), "three".to_string()], + nested: NestedStruct { + value: 9876543210, + name: "nested_test".to_string(), + }, + }; + + // Serialize to GZip + let compressed = + to_gzip_bytes(&test_struct).expect("Failed to serialize and compress struct"); + + // Deserialize from GZip + let read_struct: TestStruct = + from_gzip_bytes(&compressed).expect("Failed to decompress and deserialize struct"); + + assert_eq!(read_struct, test_struct); + } + + #[test] + fn test_gzip_compression_ratio() { + let mut compound = NbtCompound::new(); + + // Create a compound with repetitive data (should compress well) + for _i in 0..1000 { + compound.put("repeated_key", NbtTag::String("this is a test string that will be repeated many times to demonstrate compression".to_string())); + } + + let uncompressed = compound.child_tags.len() * 100; // rough estimate + let compressed = write_gzip_compound_tag(&compound).expect("Failed to compress compound"); + + println!("Uncompressed size (est): {} bytes", uncompressed); + println!("Compressed size: {} bytes", compressed.len()); + println!( + "Compression ratio: {:.2}x", + uncompressed as f64 / compressed.len() as f64 + ); + + // Just ensure we can read it back - actual compression ratio will vary + let _ = read_gzip_compound_tag(&compressed).expect("Failed to decompress compound"); + } + + #[test] + fn test_gzip_invalid_data() { + // Try to read from invalid data + let invalid_data = vec![1, 2, 3, 4, 5]; // Not valid GZip data + let result = read_gzip_compound_tag(&invalid_data); + assert!(result.is_err()); + } + + #[test] + fn test_roundtrip_with_arrays() { + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct ArrayTest { + byte_array: Vec, + int_array: Vec, + string_array: Vec, + } + + let test_struct = ArrayTest { + byte_array: vec![1, 2, 3, 4, 5], + int_array: vec![100, 200, 300, 400, 500], + string_array: vec!["one".to_string(), "two".to_string(), "three".to_string()], + }; + + let compressed = to_gzip_bytes(&test_struct).expect("Failed to serialize and compress"); + let read_struct: ArrayTest = + from_gzip_bytes(&compressed).expect("Failed to decompress and deserialize"); + + assert_eq!(read_struct, test_struct); + } + + #[test] + fn test_roundtrip_with_map() { + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct MapTest { + string_map: HashMap, + int_map: HashMap, + } + + let mut string_map = HashMap::new(); + string_map.insert("key1".to_string(), "value1".to_string()); + string_map.insert("key2".to_string(), "value2".to_string()); + + let mut int_map = HashMap::new(); + int_map.insert("one".to_string(), 1); + int_map.insert("two".to_string(), 2); + + let test_struct = MapTest { + string_map, + int_map, + }; + + let compressed = to_gzip_bytes(&test_struct).expect("Failed to serialize and compress"); + let read_struct: MapTest = + from_gzip_bytes(&compressed).expect("Failed to decompress and deserialize"); + + assert_eq!(read_struct, test_struct); + } +} diff --git a/pumpkin/src/command/commands/experience.rs b/pumpkin/src/command/commands/experience.rs index fe322596f..8158bd116 100644 --- a/pumpkin/src/command/commands/experience.rs +++ b/pumpkin/src/command/commands/experience.rs @@ -166,24 +166,23 @@ impl Executor { } } - async fn handle_modify( + 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; + if self.mode == Mode::Add { + target.add_experience_levels(amount); } else { - target.set_experience_level(amount, true).await; + target.set_experience_level(amount, true); } } ExpType::Points => { - if mode == Mode::Add { - target.add_experience_points(amount).await; + if self.mode == Mode::Add { + target.add_experience_points(amount); } else { // target.set_experience_points(amount).await; This could let current_level = target.experience_level.load(Ordering::Relaxed); @@ -193,7 +192,7 @@ impl Executor { return Err("commands.experience.set.points.invalid"); } - target.set_experience_points(amount).await; + target.set_experience_points(amount); } } } @@ -243,10 +242,7 @@ impl CommandExecutor for Executor { } for target in targets { - match self - .handle_modify(target, amount, self.exp_type.unwrap(), self.mode) - .await - { + match self.handle_modify(target, amount, self.exp_type.unwrap()) { Ok(()) => { let msg = Self::get_success_message( self.mode, diff --git a/pumpkin/src/data/mod.rs b/pumpkin/src/data/mod.rs index 2a0f20c6c..8aab6cc35 100644 --- a/pumpkin/src/data/mod.rs +++ b/pumpkin/src/data/mod.rs @@ -9,6 +9,7 @@ pub mod op_data; pub mod banlist_serializer; pub mod banned_ip_data; pub mod banned_player_data; +pub mod player_data; pub trait LoadJSONConfiguration { #[must_use] diff --git a/pumpkin/src/data/player_data.rs b/pumpkin/src/data/player_data.rs new file mode 100644 index 000000000..37099f1c1 --- /dev/null +++ b/pumpkin/src/data/player_data.rs @@ -0,0 +1,492 @@ +use std::{ + collections::HashMap, + fs::{File, create_dir_all}, + io, + io::{Read, Write}, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; + +use pumpkin_config::ADVANCED_CONFIG; +use pumpkin_nbt::compound::NbtCompound; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::{ + entity::{NBTStorage, player::Player}, + server::Server, +}; + +/// Manages the storage and retrieval of player data from disk and memory cache. +/// +/// This struct provides functions to load and save player data to/from NBT files, +/// with a memory cache to handle player disconnections temporarily. +pub struct PlayerDataStorage { + /// Path to the directory where player data is stored + data_path: PathBuf, + /// In-memory cache of recently disconnected players' data + cache: Mutex>, + /// How long to keep player data in cache after disconnection + cache_expiration: Duration, + /// Maximum number of entries in the cache + max_cache_entries: usize, + /// Whether player data saving is enabled + save_enabled: bool, + /// Whether to cache player data + cache_enabled: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum PlayerDataError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + #[error("NBT error: {0}")] + Nbt(String), + #[error("Player data not found for UUID: {0}")] + NotFound(Uuid), +} + +impl PlayerDataStorage { + /// Creates a new `PlayerDataStorage` with the specified data path and cache expiration time. + pub fn new(data_path: impl Into, cache_expiration: Duration) -> Self { + let path = data_path.into(); + if !path.exists() { + if let Err(e) = create_dir_all(&path) { + log::error!( + "Failed to create player data directory at {:?}: {}", + path, + e + ); + } + } + + let config = &ADVANCED_CONFIG.player_data; + + Self { + data_path: path, + cache: Mutex::new(HashMap::new()), + cache_expiration, + max_cache_entries: config.max_cache_entries as usize, + save_enabled: config.save_player_data, + cache_enabled: config.cache_player_data, + } + } + + /// Returns the path for a player's data file based on their UUID. + fn get_player_data_path(&self, uuid: &Uuid) -> PathBuf { + self.data_path.join(format!("{uuid}.dat")) + } + + /// Loads player data from NBT file or cache. + /// + /// This function first checks if player data exists in the cache. + /// If not, it attempts to load the data from a .dat file on disk. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the player to load data for. + /// + /// # Returns + /// + /// A Result containing either the player's NBT data or an error. + pub async fn load_player_data(&self, uuid: &Uuid) -> Result { + // If player data saving is disabled, return empty data + if !self.save_enabled { + return Ok(NbtCompound::new()); + } + + // Check cache first if caching is enabled + if self.cache_enabled { + let cache = self.cache.lock().await; + if let Some((data, _)) = cache.get(uuid) { + log::debug!( + "Loaded player data for {} from cache with data {:?}", + uuid, + data + ); + return Ok(data.clone()); + } + } + + // If not in cache, load from disk + let path = self.get_player_data_path(uuid); + if !path.exists() { + log::debug!("No player data file found for {}", uuid); + return Err(PlayerDataError::NotFound(*uuid)); + } + + // Offload file I/O to a separate tokio task + let uuid_copy = *uuid; + let nbt = tokio::task::spawn_blocking(move || -> Result { + let mut file = File::open(&path).map_err(PlayerDataError::Io)?; + let mut data = Vec::new(); + file.read_to_end(&mut data).map_err(PlayerDataError::Io)?; + + pumpkin_nbt::nbt_compress::read_gzip_compound_tag(&data) + .map_err(|e| PlayerDataError::Nbt(e.to_string())) + }) + .await + .unwrap_or_else(|e| { + log::error!( + "Task error when loading player data for {}: {}", + uuid_copy, + e + ); + Err(PlayerDataError::Nbt(format!("Task join error: {e}"))) + })?; + + log::debug!("Loaded player data for {} from disk", uuid); + Ok(nbt) + } + + /// Saves player data to NBT file and updates cache. + /// + /// This function saves the player's data to a .dat file on disk and also + /// updates the in-memory cache with the latest data. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the player to save data for. + /// * `data` - The NBT compound data to save. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn save_player_data( + &self, + uuid: &Uuid, + data: NbtCompound, + ) -> Result<(), PlayerDataError> { + // Skip saving if disabled in config + if !self.save_enabled { + return Ok(()); + } + + let path = self.get_player_data_path(uuid); + + // Update cache if caching is enabled + if self.cache_enabled { + let mut cache = self.cache.lock().await; + cache.insert(*uuid, (data.clone(), Instant::now())); + }; + + // Run disk I/O in a separate tokio task + let uuid_copy = *uuid; + tokio::spawn(async move { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + if let Err(e) = create_dir_all(parent) { + log::error!( + "Failed to create player data directory for {}: {}", + uuid_copy, + e + ); + return; + } + } + + // Compress the NBT data + let compressed = match pumpkin_nbt::nbt_compress::write_gzip_compound_tag(&data) { + Ok(compressed) => compressed, + Err(e) => { + log::error!("Failed to compress player data for {}: {}", uuid_copy, e); + return; + } + }; + + // Save to disk + match File::create(&path) { + Ok(mut file) => { + if let Err(e) = file.write_all(&compressed) { + log::error!("Failed to write player data for {}: {}", uuid_copy, e); + } else { + log::debug!("Saved player data for {} to disk", uuid_copy); + } + } + Err(e) => { + log::error!("Failed to create player data file for {}: {}", uuid_copy, e); + } + } + }); + Ok(()) + } + + /// Caches player data on disconnect to avoid loading from disk on rejoin. + /// + /// This function is used when a player disconnects, to temporarily cache their + /// data in memory with an expiration timestamp. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the player who disconnected. + /// * `data` - The NBT compound data to cache. + pub async fn cache_on_disconnect(&self, uuid: &Uuid, data: NbtCompound) { + // Skip if caching is disabled + if !self.cache_enabled { + return; + } + + // Clone the data to avoid holding locks during complex operations + let data_clone = data.clone(); + + // Use a scope to limit the lock duration + { + let mut cache = self.cache.lock().await; + + // Check if we need to remove an entry to stay under max_cache_entries + if cache.len() >= self.max_cache_entries && !cache.contains_key(uuid) { + // Find the oldest entry + if let Some(oldest_uuid) = cache + .iter() + .min_by_key(|(_, (_, timestamp))| *timestamp) + .map(|(uuid, _)| *uuid) + { + cache.remove(&oldest_uuid); + } + } + + // Insert the new entry + cache.insert(*uuid, (data_clone, Instant::now())); + }; + + log::debug!("Cached player data for {} on disconnect", uuid); + } + + /// Removes expired player data from the cache. + /// + /// This function should be called periodically to clean up cached player data + /// that has exceeded its expiration time. + pub async fn clean_expired_cache(&self) { + if !self.cache_enabled { + return; + } + + let mut cache = self.cache.lock().await; + let now = Instant::now(); + let expired: Vec = cache + .iter() + .filter(|(_, (_, timestamp))| now.duration_since(*timestamp) > self.cache_expiration) + .map(|(uuid, _)| *uuid) + .collect(); + + for uuid in expired { + cache.remove(&uuid); + } + + // Release lock before waiting for tasks + drop(cache); + } + + /// Loads player data and applies it to a player. + /// + /// This function loads a player's data and applies it to their Player instance. + /// For new players, it creates default data without errors. + /// + /// # Arguments + /// + /// * `player` - The player to load data for and apply to. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn load_and_apply_data_to_player( + &self, + player: &mut Player, + ) -> Result<(), PlayerDataError> { + let uuid = &player.gameprofile.id; + match self.load_player_data(uuid).await { + Ok(mut data) => { + player.read_nbt(&mut data).await; + Ok(()) + } + Err(PlayerDataError::NotFound(_)) => { + // For new players, just continue with default data + log::debug!("Creating new player data for {}", uuid); + Ok(()) + } + Err(e) => { + if self.save_enabled { + // Only log as error if player data saving is enabled + log::error!("Error loading player data for {}: {}", uuid, e); + } else { + // Otherwise just log as info since it's expected + log::debug!("Not loading player data for {} (saving disabled)", uuid); + } + // Continue with default data even if there's an error + Ok(()) + } + } + } + + /// Extracts and saves data from a player. + /// + /// This function extracts NBT data from a player and saves it to disk. + /// + /// # Arguments + /// + /// * `player` - The player to extract and save data for. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn extract_data_and_save_player( + &self, + player: &Player, + ) -> Result<(), PlayerDataError> { + let uuid = &player.gameprofile.id; + let mut nbt = NbtCompound::new(); + player.write_nbt(&mut nbt).await; + self.save_player_data(uuid, nbt).await + } +} + +/// Helper for managing player data in the server context. +/// +/// This struct provides server-wide access to the `PlayerDataStorage` and +/// convenience methods for player handling. +pub struct ServerPlayerData { + storage: Arc, + save_interval: Duration, + last_cleanup: Mutex, + cleanup_interval: Duration, + last_save: Mutex, +} + +impl ServerPlayerData { + /// Creates a new `ServerPlayerData` with specified configuration. + pub fn new( + data_path: impl Into, + cache_expiration: Duration, + save_interval: Duration, + cleanup_interval: Duration, + ) -> Self { + Self { + storage: Arc::new(PlayerDataStorage::new(data_path, cache_expiration)), + save_interval, + last_cleanup: Mutex::new(Instant::now()), + cleanup_interval, + last_save: Mutex::new(Instant::now()), + } + } + + /// Handles a player joining the server. + /// + /// This function loads player data and applies it to a newly joined player. + /// + /// # Arguments + /// + /// * `player` - The player who joined. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn handle_player_join(&self, player: &mut Player) -> Result<(), PlayerDataError> { + self.storage.load_and_apply_data_to_player(player).await + } + + /// Handles a player leaving the server. + /// + /// This function saves player data when they disconnect. + /// + /// # Arguments + /// + /// * `player` - The player who left. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn handle_player_leave(&self, player: &Player) -> Result<(), PlayerDataError> { + let mut nbt = NbtCompound::new(); + player.write_nbt(&mut nbt).await; + + // First cache it if caching is enabled + self.storage + .cache_on_disconnect(&player.gameprofile.id, nbt.clone()) + .await; + + // Then save to disk + self.storage + .save_player_data(&player.gameprofile.id, nbt) + .await?; + + Ok(()) + } + + /// Performs periodic maintenance tasks. + /// + /// This function should be called regularly to save player data and clean + /// expired cache entries. + pub async fn tick(&self, server: &Server) -> Result<(), PlayerDataError> { + let now = Instant::now(); + + // Check if cleanup is needed + { + let mut last_cleanup = self.last_cleanup.lock().await; + if now.duration_since(*last_cleanup) >= self.cleanup_interval { + self.storage.clean_expired_cache().await; + *last_cleanup = now; + } + } + + // Only save players periodically based on save_interval + let should_save = { + let mut last_save = self.last_save.lock().await; + let should_save = now.duration_since(*last_save) >= self.save_interval; + + if should_save { + *last_save = now; + } + + should_save + }; + + if should_save && self.storage.save_enabled { + // Save all online players periodically across all worlds + for world in server.worlds.read().await.iter() { + let players = world.players.read().await; + for player in players.values() { + let mut nbt = NbtCompound::new(); + player.write_nbt(&mut nbt).await; + + // Save to disk periodically to prevent data loss on server crash + if let Err(e) = self + .storage + .save_player_data(&player.gameprofile.id, nbt) + .await + { + log::error!( + "Failed to save player data for {}: {}", + player.gameprofile.id, + e + ); + } + } + } + + log::debug!("Periodic player data save completed"); + } + + Ok(()) + } + + /// Saves all players' data immediately. + /// + /// This function immediately saves all online players' data to disk. + /// Useful for server shutdown or backup operations. + pub async fn save_all_players(&self, server: &Server) -> Result<(), PlayerDataError> { + let mut total_players = 0; + + // Save players from all worlds + for world in server.worlds.read().await.iter() { + let players = world.players.read().await; + for player in players.values() { + self.storage.extract_data_and_save_player(player).await?; + total_players += 1; + } + } + + log::debug!("Saved data for {} online players", total_players); + Ok(()) + } +} diff --git a/pumpkin/src/entity/hunger.rs b/pumpkin/src/entity/hunger.rs index e1810f5b2..b0fcda828 100644 --- a/pumpkin/src/entity/hunger.rs +++ b/pumpkin/src/entity/hunger.rs @@ -8,8 +8,8 @@ pub struct HungerManager { pub level: AtomicCell, /// The food saturation level. pub saturation: AtomicCell, - exhaustion: AtomicCell, - tick_timer: AtomicCell, + pub exhaustion: AtomicCell, + pub tick_timer: AtomicCell, } impl Default for HungerManager { diff --git a/pumpkin/src/entity/player.rs b/pumpkin/src/entity/player.rs index 570748f54..f2b3d6863 100644 --- a/pumpkin/src/entity/player.rs +++ b/pumpkin/src/entity/player.rs @@ -9,6 +9,26 @@ use std::{ time::{Duration, Instant}, }; +use super::{ + Entity, EntityBase, EntityId, NBTStorage, + combat::{self, AttackType, player_attack_sound}, + effect::Effect, + hunger::HungerManager, + item::ItemEntity, +}; +use crate::{ + block, + command::{client_suggestions, dispatcher::CommandDispatcher}, + data::op_data::OPERATOR_CONFIG, + net::{Client, PlayerConfig}, + plugin::player::{ + player_change_world::PlayerChangeWorldEvent, + player_gamemode_change::PlayerGamemodeChangeEvent, player_teleport::PlayerTeleportEvent, + }, + server::Server, + world::World, +}; +use crate::{error::PumpkinError, net::GameProfile}; use async_trait::async_trait; use crossbeam::atomic::AtomicCell; use pumpkin_config::{BASIC_CONFIG, advanced_config}; @@ -65,27 +85,6 @@ use pumpkin_util::{ use pumpkin_world::{cylindrical_chunk_iterator::Cylindrical, item::ItemStack, level::SyncChunk}; use tokio::sync::{Mutex, Notify, RwLock}; -use super::{ - Entity, EntityBase, EntityId, NBTStorage, - combat::{self, AttackType, player_attack_sound}, - effect::Effect, - hunger::HungerManager, - item::ItemEntity, -}; -use crate::{ - block, - command::{client_suggestions, dispatcher::CommandDispatcher}, - data::op_data::OPERATOR_CONFIG, - net::{Client, PlayerConfig}, - plugin::player::{ - player_change_world::PlayerChangeWorldEvent, - player_gamemode_change::PlayerGamemodeChangeEvent, player_teleport::PlayerTeleportEvent, - }, - server::Server, - world::World, -}; -use crate::{error::PumpkinError, net::GameProfile}; - use super::living::LivingEntity; enum BatchState { @@ -210,6 +209,11 @@ pub struct Player { pub last_keep_alive_time: AtomicCell, /// The amount of ticks since the player's last attack. pub last_attacked_ticks: AtomicU32, + /// The player's last known experience level. + pub last_sent_xp: AtomicI32, + pub last_sent_health: AtomicI32, + pub last_sent_food: AtomicU32, + pub last_food_saturation: AtomicBool, /// The player's permission level. pub permission_lvl: AtomicCell, /// Tell tasks to stop if we are closing. @@ -305,6 +309,10 @@ impl Player { experience_points: AtomicI32::new(0), // Default to sending 16 chunks per tick. chunk_manager: Mutex::new(ChunkManager::new(16)), + last_sent_xp: AtomicI32::new(-1), + last_sent_health: AtomicI32::new(-1), + last_sent_food: AtomicU32::new(0), + last_food_saturation: AtomicBool::new(true), } } @@ -592,6 +600,10 @@ impl Player { self.living_entity.tick(server).await; self.hunger_manager.tick(self).await; + // experience handling + self.tick_experience().await; + self.tick_health().await; + // Timeout/keep alive handling self.tick_client_load_timeout(); @@ -1033,6 +1045,24 @@ impl Player { .await; } + pub async fn tick_health(&self) { + let health = self.living_entity.health.load() as i32; + let food = self.hunger_manager.level.load(); + let saturation = self.hunger_manager.saturation.load(); + + let last_health = self.last_sent_health.load(Ordering::Relaxed); + let last_food = self.last_sent_food.load(Ordering::Relaxed); + let last_saturation = self.last_food_saturation.load(Ordering::Relaxed); + + if health != last_health || food != last_food || (saturation == 0.0) != last_saturation { + self.last_sent_health.store(health, Ordering::Relaxed); + self.last_sent_food.store(food, Ordering::Relaxed); + self.last_food_saturation + .store(saturation == 0.0, Ordering::Relaxed); + self.send_health().await; + } + } + pub async fn set_health(&self, health: f32) { self.living_entity.set_health(health).await; self.send_health().await; @@ -1238,19 +1268,31 @@ impl Player { .await; } + pub async fn tick_experience(&self) { + let level = self.experience_level.load(Ordering::Relaxed); + if self.last_sent_xp.load(Ordering::Relaxed) != level { + let progress = self.experience_progress.load(); + let points = self.experience_points.load(Ordering::Relaxed); + + self.last_sent_xp.store(level, Ordering::Relaxed); + + self.client + .send_packet(&CSetExperience::new( + progress.clamp(0.0, 1.0), + points.into(), + level.into(), + )) + .await; + } + } + /// Sets the player's experience level and notifies the client. 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), - points.into(), - level.into(), - )) - .await; + self.last_sent_xp.store(-1, Ordering::Relaxed); + self.tick_experience().await; } /// Sets the player's experience level directly. @@ -1325,8 +1367,7 @@ impl Player { } let progress = new_points as f32 / max_points as f32; - self.set_experience(current_level, progress, new_points) - .await; + self.set_experience(current_level, progress, new_points).await; true } @@ -1350,12 +1391,23 @@ impl NBTStorage for Player { "SelectedItemSlot", 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); + nbt.put_byte("playerGameType", self.gamemode.load() as i8); + + // Store food level, saturation, exhaustion, and tick timer + nbt.put_int("foodLevel", self.hunger_manager.level.load() as i32); + nbt.put_float("foodSaturationLevel", self.hunger_manager.saturation.load()); + nbt.put_float("foodExhaustionLevel", self.hunger_manager.exhaustion.load()); + nbt.put_int( + "foodTickTimer", + self.hunger_manager.tick_timer.load() as i32, + ); } async fn read_nbt(&mut self, nbt: &mut NbtCompound) { @@ -1364,6 +1416,25 @@ impl NBTStorage for Player { nbt.get_int("SelectedItemSlot").unwrap_or(0) as usize; self.abilities.lock().await.read_nbt(nbt).await; + self.gamemode.store( + GameMode::try_from(nbt.get_byte("playerGameType").unwrap_or(0)) + .unwrap_or(GameMode::Survival), + ); + + // Load food level, saturation, exhaustion, and tick timer + self.hunger_manager + .level + .store(nbt.get_int("foodLevel").unwrap_or(20) as u32); + self.hunger_manager + .saturation + .store(nbt.get_float("foodSaturationLevel").unwrap_or(5.0)); + self.hunger_manager + .exhaustion + .store(nbt.get_float("foodExhaustionLevel").unwrap_or(0.0)); + self.hunger_manager + .tick_timer + .store(nbt.get_int("foodTickTimer").unwrap_or(0) as u32); + // 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); diff --git a/pumpkin/src/error.rs b/pumpkin/src/error.rs index 10cd79ca3..c0f65ed7e 100644 --- a/pumpkin/src/error.rs +++ b/pumpkin/src/error.rs @@ -1,3 +1,4 @@ +use crate::data::player_data::PlayerDataError; use log::log; use pumpkin_inventory::InventoryError; use pumpkin_protocol::bytebuf::ReadingError; @@ -65,3 +66,21 @@ impl PumpkinError for ReadingError { None } } + +impl PumpkinError for PlayerDataError { + fn is_kick(&self) -> bool { + false + } + + fn severity(&self) -> log::Level { + log::Level::Warn + } + + fn client_kick_reason(&self) -> Option { + match self { + Self::Io(err) => Some(format!("Failed to load player data: {err}")), + Self::Nbt(err) => Some(format!("Failed to parse player data: {err}")), + Self::NotFound(uuid) => Some(format!("Player data not found for UUID: {uuid}")), + } + } +} diff --git a/pumpkin/src/lib.rs b/pumpkin/src/lib.rs index 886c00a5a..9efe985bf 100644 --- a/pumpkin/src/lib.rs +++ b/pumpkin/src/lib.rs @@ -359,6 +359,16 @@ impl PumpkinServer { } //TODO: Move these somewhere less likely to be forgotten + log::debug!("Cleaning up player for id {}", id); + + // Save player data on disconnect + if let Err(e) = server + .player_data_storage + .handle_player_leave(&player) + .await + { + log::error!("Failed to save player data on disconnect: {}", e); + } // Remove the player from its world player.remove().await; @@ -377,6 +387,15 @@ impl PumpkinServer { log::info!("Stopped accepting incoming connections"); + if let Err(e) = self + .server + .player_data_storage + .save_all_players(&self.server) + .await + { + log::error!("Error saving all players during shutdown: {}", e); + } + let kick_message = TextComponent::text("Server stopped"); for player in self.server.get_all_players().await { player.kick(kick_message.clone()).await; diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index 5a9517abd..0189a70c8 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -1,7 +1,8 @@ use crate::block::registry::BlockRegistry; use crate::command::commands::default_dispatcher; use crate::command::commands::defaultgamemode::DefaultGamemode; -use crate::entity::EntityId; +use crate::data::player_data::ServerPlayerData; +use crate::entity::{Entity, EntityId}; use crate::item::registry::ItemRegistry; use crate::net::EncryptionError; use crate::plugin::player::player_login::PlayerLoginEvent; @@ -72,6 +73,8 @@ pub struct Server { pub bossbars: Mutex, /// The default gamemode when a player joins the server (reset every restart) pub defaultgamemode: Mutex, + /// Manages player data storage + pub player_data_storage: ServerPlayerData, } impl Server { @@ -124,6 +127,12 @@ impl Server { defaultgamemode: Mutex::new(DefaultGamemode { gamemode: BASIC_CONFIG.default_gamemode, }), + player_data_storage: ServerPlayerData::new( + "./world/playerdata", // TODO: handle world name in config + Duration::from_secs(3600), // TODO: handle cache expiration in config + Duration::from_secs(300), // TODO: handle save interval in config + Duration::from_secs(600), // TODO: handle cleanup interval in config + ), } } @@ -170,10 +179,23 @@ impl Server { // TODO: select default from config let world = &self.worlds.read().await[0]; - let player = Arc::new(Player::new(client, world.clone(), gamemode).await); + let mut player = Player::new(client, world.clone(), gamemode).await; + + // Load player data + if let Err(e) = self + .player_data_storage + .handle_player_join(&mut player) + .await + { + // This should never happen now with the updated code that always returns Ok() + log::error!("Unexpected error loading player data: {}", e); + } + + // Wrap in Arc after data is loaded + let player = Arc::new(player); + send_cancellable! {{ PlayerLoginEvent::new(player.clone(), TextComponent::text("You have been kicked from the server")); - 'after: { world .add_player(player.gameprofile.id, player.clone()) @@ -439,5 +461,9 @@ impl Server { for world in self.worlds.read().await.iter() { world.tick(self).await; } + + if let Err(e) = self.player_data_storage.tick(self).await { + log::error!("Error ticking player data: {}", e); + } } } diff --git a/pumpkin/src/world/mod.rs b/pumpkin/src/world/mod.rs index ecd93bf84..ea4fce98c 100644 --- a/pumpkin/src/world/mod.rs +++ b/pumpkin/src/world/mod.rs @@ -387,10 +387,12 @@ impl World { let yaw = info.spawn_angle; let pitch = 10.0; - let top = self - .get_top_block(Vector2::new(position.x as i32, position.z as i32)) - .await; - position.y = f64::from(top + 1); + // teleport + let position = player.position(); + let velocity = player.living_entity.entity.velocity.load(); + + let yaw = player.living_entity.entity.yaw.load(); //info.spawn_angle; + let pitch = player.living_entity.entity.pitch.load(); log::debug!("Sending player teleport to {}", player.gameprofile.name); player.request_teleport(position, yaw, pitch).await; @@ -451,7 +453,6 @@ impl World { // Spawn the player for every client. self.broadcast_packet_except( &[player.gameprofile.id], - // TODO: add velo &CSpawnEntity::new( entity_id.into(), gameprofile.id, @@ -461,7 +462,7 @@ impl World { yaw, yaw, 0.into(), - Vector3::new(0.0, 0.0, 0.0), + velocity, ), ) .await; @@ -483,7 +484,7 @@ impl World { entity.pitch.load(), entity.head_yaw.load(), 0.into(), - Vector3::new(0.0, 0.0, 0.0), + entity.velocity.load(), )) .await; } From d665c756585458be9e367fd8fac674be9c3dbc36 Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Sat, 1 Mar 2025 14:59:52 +0100 Subject: [PATCH 02/12] fix(player_data): ensure player data is save before continuing --- pumpkin-config/src/player_data.rs | 9 +- pumpkin-nbt/src/nbt_compress.rs | 233 ++++++++++++++++++------------ pumpkin/src/data/player_data.rs | 216 ++++++--------------------- pumpkin/src/error.rs | 1 - pumpkin/src/server/mod.rs | 14 +- 5 files changed, 199 insertions(+), 274 deletions(-) diff --git a/pumpkin-config/src/player_data.rs b/pumpkin-config/src/player_data.rs index 704c5b70a..04fe0019f 100644 --- a/pumpkin-config/src/player_data.rs +++ b/pumpkin-config/src/player_data.rs @@ -5,18 +5,15 @@ use serde::{Deserialize, Serialize}; pub struct PlayerDataConfig { /// Is Player Data saving enabled? pub save_player_data: bool, - /// Is Player Data should be cached? - pub cache_player_data: bool, - /// Maximum amount of players to cache. - pub max_cache_entries: u16, + /// Time interval in seconds to save player data + pub save_player_cron_interval: u64, } impl Default for PlayerDataConfig { fn default() -> Self { Self { save_player_data: true, - cache_player_data: true, - max_cache_entries: 256, + save_player_cron_interval: 300, } } } diff --git a/pumpkin-nbt/src/nbt_compress.rs b/pumpkin-nbt/src/nbt_compress.rs index 5a6ddad7d..15f0eff52 100644 --- a/pumpkin-nbt/src/nbt_compress.rs +++ b/pumpkin-nbt/src/nbt_compress.rs @@ -1,30 +1,24 @@ +use crate::deserializer::ReadAdaptor; use crate::{Error, Nbt, NbtCompound, deserializer, serializer}; use flate2::{Compression, read::GzDecoder, write::GzEncoder}; use std::io::{Read, Write}; -/// Reads a GZipped NBT compound tag. -/// -/// This function takes a byte slice containing a GZipped NBT compound tag and returns the deserialized compound. +/// Reads a GZipped NBT compound tag from any reader. /// /// # Arguments /// -/// * `compressed_data` - A byte slice containing the GZipped NBT data +/// * `input` - Any type implementing the Read trait containing GZipped NBT data /// /// # Returns /// /// A Result containing either the parsed NbtCompound or an Error -pub fn read_gzip_compound_tag(compressed_data: &[u8]) -> Result { - // Create a GZip decoder - let mut decoder = GzDecoder::new(compressed_data); - - // Decompress the data - let mut decompressed_data = Vec::new(); - decoder - .read_to_end(&mut decompressed_data) - .map_err(Error::Incomplete)?; +pub fn read_gzip_compound_tag(input: impl Read) -> Result { + // Create a GZip decoder and directly chain it to the NBT reader + let decoder = GzDecoder::new(input); + let mut reader = ReadAdaptor::new(decoder); - // Read the NBT data - let nbt = Nbt::read(&mut deserializer::ReadAdaptor::new(&decompressed_data[..]))?; + // Read the NBT data directly from the decoder stream + let nbt = Nbt::read(&mut reader)?; Ok(nbt.root_tag) } @@ -35,84 +29,82 @@ pub fn read_gzip_compound_tag(compressed_data: &[u8]) -> Result Result, Error> { - // First serialize the NBT data +pub fn write_gzip_compound_tag(compound: &NbtCompound, output: impl Write) -> Result<(), Error> { + // Create a GZip encoder that writes to the output + let mut encoder = GzEncoder::new(output, Compression::default()); + + // Create an NBT wrapper and write directly to the encoder let nbt = Nbt::new(String::new(), compound.clone()); - let serialized = nbt.write(); - - // Then compress it with GZip - let mut compressed_data = Vec::new(); - { - let mut encoder = GzEncoder::new(&mut compressed_data, Compression::default()); - encoder.write_all(&serialized).map_err(Error::Incomplete)?; - encoder.finish().map_err(Error::Incomplete)?; - } + nbt.write_to_writer(&mut encoder) + .map_err(Error::Incomplete)?; + + // Finish the encoder to ensure all data is written + encoder.finish().map_err(Error::Incomplete)?; - Ok(compressed_data) + Ok(()) } -/// Reads a GZipped NBT structure. -/// -/// This function takes a byte slice containing a GZipped NBT structure and deserializes it into a user-provided type. +/// Convenience function that returns compressed bytes +pub fn write_gzip_compound_tag_to_bytes(compound: &NbtCompound) -> Result, Error> { + let mut buffer = Vec::new(); + write_gzip_compound_tag(compound, &mut buffer)?; + Ok(buffer) +} + +/// Reads a GZipped NBT structure into a Rust type. /// /// # Arguments /// -/// * `compressed_data` - A byte slice containing the GZipped NBT data +/// * `input` - Any type implementing the Read trait containing GZipped NBT data /// /// # Returns /// -/// A Result containing either the parsed structure or an Error -pub fn from_gzip_bytes<'a, T>(compressed_data: &[u8]) -> Result +/// A Result containing either the deserialized type or an Error +pub fn from_gzip_bytes<'a, T, R>(input: R) -> Result where T: serde::Deserialize<'a>, + R: Read, { - // Create a GZip decoder - let mut decoder = GzDecoder::new(compressed_data); - - // Decompress the data - let mut decompressed_data = Vec::new(); - decoder - .read_to_end(&mut decompressed_data) - .map_err(Error::Incomplete)?; - - // Deserialize the NBT data - deserializer::from_bytes(&decompressed_data[..]) + // Create a GZip decoder and directly use it for deserialization + let decoder = GzDecoder::new(input); + deserializer::from_bytes(decoder) } -/// Writes a GZipped NBT structure. -/// -/// This function takes a serializable structure and writes it as a GZipped byte vector. +/// Writes a Rust type as GZipped NBT to any writer. /// /// # Arguments /// /// * `value` - The value to serialize and compress +/// * `output` - Any type implementing the Write trait where the compressed data will be written /// /// # Returns /// -/// A Result containing either the compressed data as a byte vector or an Error -pub fn to_gzip_bytes(value: &T) -> Result, Error> +/// A Result indicating success or an Error +pub fn to_gzip_bytes(value: &T, output: W) -> Result<(), Error> where T: serde::Serialize, + W: Write, { - // First serialize the NBT data - let mut uncompressed_data = Vec::new(); - serializer::to_bytes(value, &mut uncompressed_data)?; - - // Then compress it with GZip - let mut compressed_data = Vec::new(); - { - let mut encoder = GzEncoder::new(&mut compressed_data, Compression::default()); - encoder - .write_all(&uncompressed_data) - .map_err(Error::Incomplete)?; - encoder.finish().map_err(Error::Incomplete)?; - } + // Create a GZip encoder that writes to the output + let encoder = GzEncoder::new(output, Compression::default()); - Ok(compressed_data) + // Serialize directly to the encoder + serializer::to_bytes(value, encoder) +} + +/// Convenience function that returns compressed bytes +pub fn to_gzip_bytes_vec(value: &T) -> Result, Error> +where + T: serde::Serialize, +{ + let mut buffer = Vec::new(); + to_gzip_bytes(value, &mut buffer)?; + Ok(buffer) } #[cfg(test)] @@ -120,12 +112,14 @@ mod tests { use crate::{ NbtCompound, nbt_compress::{ - from_gzip_bytes, read_gzip_compound_tag, to_gzip_bytes, write_gzip_compound_tag, + from_gzip_bytes, read_gzip_compound_tag, to_gzip_bytes, to_gzip_bytes_vec, + write_gzip_compound_tag, write_gzip_compound_tag_to_bytes, }, tag::NbtTag, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; + use std::io::Cursor; #[test] fn test_gzip_read_write_compound() { @@ -145,12 +139,13 @@ mod tests { nested.put_int("nested_int", 42); compound.put_component("nested_compound", nested); - // Write to GZip - let compressed = write_gzip_compound_tag(&compound).expect("Failed to compress compound"); + // Write to GZip using streaming + let mut buffer = Vec::new(); + write_gzip_compound_tag(&compound, &mut buffer).expect("Failed to compress compound"); - // Read from GZip + // Read from GZip using streaming let read_compound = - read_gzip_compound_tag(&compressed).expect("Failed to decompress compound"); + read_gzip_compound_tag(Cursor::new(&buffer)).expect("Failed to decompress compound"); // Verify values assert_eq!(read_compound.get_byte("byte_value"), Some(123)); @@ -173,13 +168,30 @@ mod tests { } } + #[test] + fn test_gzip_convenience_methods() { + // Create a test compound + let mut compound = NbtCompound::new(); + compound.put_int("test_value", 12345); + + // Test convenience method for writing + let buffer = + write_gzip_compound_tag_to_bytes(&compound).expect("Failed to compress compound"); + + // Test streaming read from the buffer + let read_compound = + read_gzip_compound_tag(Cursor::new(buffer)).expect("Failed to decompress compound"); + + assert_eq!(read_compound.get_int("test_value"), Some(12345)); + } + #[test] fn test_gzip_empty_compound() { let compound = NbtCompound::new(); - let compressed = - write_gzip_compound_tag(&compound).expect("Failed to compress empty compound"); - let read_compound = - read_gzip_compound_tag(&compressed).expect("Failed to decompress empty compound"); + let mut buffer = Vec::new(); + write_gzip_compound_tag(&compound, &mut buffer).expect("Failed to compress empty compound"); + let read_compound = read_gzip_compound_tag(Cursor::new(buffer)) + .expect("Failed to decompress empty compound"); assert_eq!(read_compound.child_tags.len(), 0); } @@ -193,10 +205,10 @@ mod tests { compound.put_int(&format!("value_{}", i), i); } - let compressed = - write_gzip_compound_tag(&compound).expect("Failed to compress large compound"); - let read_compound = - read_gzip_compound_tag(&compressed).expect("Failed to decompress large compound"); + let mut buffer = Vec::new(); + write_gzip_compound_tag(&compound, &mut buffer).expect("Failed to compress large compound"); + let read_compound = read_gzip_compound_tag(Cursor::new(buffer)) + .expect("Failed to decompress large compound"); assert_eq!(read_compound.child_tags.len(), 1000); @@ -236,15 +248,23 @@ mod tests { }, }; - // Serialize to GZip - let compressed = - to_gzip_bytes(&test_struct).expect("Failed to serialize and compress struct"); + // Test streaming serialization + let mut buffer = Vec::new(); + to_gzip_bytes(&test_struct, &mut buffer).expect("Failed to serialize and compress struct"); - // Deserialize from GZip - let read_struct: TestStruct = - from_gzip_bytes(&compressed).expect("Failed to decompress and deserialize struct"); + // Test streaming deserialization + let read_struct: TestStruct = from_gzip_bytes(Cursor::new(&buffer)) + .expect("Failed to decompress and deserialize struct"); assert_eq!(read_struct, test_struct); + + // Also test the convenience method + let buffer2 = + to_gzip_bytes_vec(&test_struct).expect("Failed to serialize and compress struct"); + let read_struct2: TestStruct = from_gzip_bytes(Cursor::new(&buffer2)) + .expect("Failed to decompress and deserialize struct"); + + assert_eq!(read_struct2, test_struct); } #[test] @@ -257,24 +277,25 @@ mod tests { } let uncompressed = compound.child_tags.len() * 100; // rough estimate - let compressed = write_gzip_compound_tag(&compound).expect("Failed to compress compound"); + let mut buffer = Vec::new(); + write_gzip_compound_tag(&compound, &mut buffer).expect("Failed to compress compound"); println!("Uncompressed size (est): {} bytes", uncompressed); - println!("Compressed size: {} bytes", compressed.len()); + println!("Compressed size: {} bytes", buffer.len()); println!( "Compression ratio: {:.2}x", - uncompressed as f64 / compressed.len() as f64 + uncompressed as f64 / buffer.len() as f64 ); // Just ensure we can read it back - actual compression ratio will vary - let _ = read_gzip_compound_tag(&compressed).expect("Failed to decompress compound"); + let _ = read_gzip_compound_tag(Cursor::new(buffer)).expect("Failed to decompress compound"); } #[test] fn test_gzip_invalid_data() { // Try to read from invalid data let invalid_data = vec![1, 2, 3, 4, 5]; // Not valid GZip data - let result = read_gzip_compound_tag(&invalid_data); + let result = read_gzip_compound_tag(Cursor::new(invalid_data)); assert!(result.is_err()); } @@ -293,9 +314,10 @@ mod tests { string_array: vec!["one".to_string(), "two".to_string(), "three".to_string()], }; - let compressed = to_gzip_bytes(&test_struct).expect("Failed to serialize and compress"); + let mut buffer = Vec::new(); + to_gzip_bytes(&test_struct, &mut buffer).expect("Failed to serialize and compress"); let read_struct: ArrayTest = - from_gzip_bytes(&compressed).expect("Failed to decompress and deserialize"); + from_gzip_bytes(Cursor::new(buffer)).expect("Failed to decompress and deserialize"); assert_eq!(read_struct, test_struct); } @@ -321,10 +343,39 @@ mod tests { int_map, }; - let compressed = to_gzip_bytes(&test_struct).expect("Failed to serialize and compress"); + let mut buffer = Vec::new(); + to_gzip_bytes(&test_struct, &mut buffer).expect("Failed to serialize and compress"); let read_struct: MapTest = - from_gzip_bytes(&compressed).expect("Failed to decompress and deserialize"); + from_gzip_bytes(Cursor::new(buffer)).expect("Failed to decompress and deserialize"); assert_eq!(read_struct, test_struct); } + + #[test] + fn test_direct_file_io() { + use std::fs::File; + use std::path::Path; + + let mut compound = NbtCompound::new(); + compound.put_int("test_value", 12345); + + // Create a temporary file path + let path = Path::new("test_nbt_file.dat"); + + // Write to file directly + { + let file = File::create(path).expect("Failed to create test file"); + write_gzip_compound_tag(&compound, file).expect("Failed to write NBT to file"); + } + + // Read from file directly + { + let file = File::open(path).expect("Failed to open test file"); + let read_compound = read_gzip_compound_tag(file).expect("Failed to read NBT from file"); + assert_eq!(read_compound.get_int("test_value"), Some(12345)); + } + + // Clean up + std::fs::remove_file(path).expect("Failed to remove test file"); + } } diff --git a/pumpkin/src/data/player_data.rs b/pumpkin/src/data/player_data.rs index 37099f1c1..f12386b27 100644 --- a/pumpkin/src/data/player_data.rs +++ b/pumpkin/src/data/player_data.rs @@ -1,16 +1,13 @@ +use crossbeam::atomic::AtomicCell; +use pumpkin_config::ADVANCED_CONFIG; +use pumpkin_nbt::compound::NbtCompound; +use std::sync::Arc; use std::{ - collections::HashMap, fs::{File, create_dir_all}, io, - io::{Read, Write}, path::PathBuf, - sync::Arc, time::{Duration, Instant}, }; - -use pumpkin_config::ADVANCED_CONFIG; -use pumpkin_nbt::compound::NbtCompound; -use tokio::sync::Mutex; use uuid::Uuid; use crate::{ @@ -25,16 +22,8 @@ use crate::{ pub struct PlayerDataStorage { /// Path to the directory where player data is stored data_path: PathBuf, - /// In-memory cache of recently disconnected players' data - cache: Mutex>, - /// How long to keep player data in cache after disconnection - cache_expiration: Duration, - /// Maximum number of entries in the cache - max_cache_entries: usize, /// Whether player data saving is enabled save_enabled: bool, - /// Whether to cache player data - cache_enabled: bool, } #[derive(Debug, thiserror::Error)] @@ -43,13 +32,11 @@ pub enum PlayerDataError { Io(#[from] io::Error), #[error("NBT error: {0}")] Nbt(String), - #[error("Player data not found for UUID: {0}")] - NotFound(Uuid), } impl PlayerDataStorage { /// Creates a new `PlayerDataStorage` with the specified data path and cache expiration time. - pub fn new(data_path: impl Into, cache_expiration: Duration) -> Self { + pub fn new(data_path: impl Into) -> Self { let path = data_path.into(); if !path.exists() { if let Err(e) = create_dir_all(&path) { @@ -65,11 +52,7 @@ impl PlayerDataStorage { Self { data_path: path, - cache: Mutex::new(HashMap::new()), - cache_expiration, - max_cache_entries: config.max_cache_entries as usize, save_enabled: config.save_player_data, - cache_enabled: config.cache_player_data, } } @@ -96,35 +79,24 @@ impl PlayerDataStorage { return Ok(NbtCompound::new()); } - // Check cache first if caching is enabled - if self.cache_enabled { - let cache = self.cache.lock().await; - if let Some((data, _)) = cache.get(uuid) { - log::debug!( - "Loaded player data for {} from cache with data {:?}", - uuid, - data - ); - return Ok(data.clone()); - } - } - // If not in cache, load from disk let path = self.get_player_data_path(uuid); if !path.exists() { log::debug!("No player data file found for {}", uuid); - return Err(PlayerDataError::NotFound(*uuid)); + return Ok(NbtCompound::new()); } // Offload file I/O to a separate tokio task let uuid_copy = *uuid; let nbt = tokio::task::spawn_blocking(move || -> Result { - let mut file = File::open(&path).map_err(PlayerDataError::Io)?; - let mut data = Vec::new(); - file.read_to_end(&mut data).map_err(PlayerDataError::Io)?; - - pumpkin_nbt::nbt_compress::read_gzip_compound_tag(&data) - .map_err(|e| PlayerDataError::Nbt(e.to_string())) + match File::open(&path) { + Ok(file) => { + // Read directly from the file with GZip decompression + pumpkin_nbt::nbt_compress::read_gzip_compound_tag(file) + .map_err(|e| PlayerDataError::Nbt(e.to_string())) + } + Err(e) => Err(PlayerDataError::Io(e)), + } }) .await .unwrap_or_else(|e| { @@ -165,15 +137,11 @@ impl PlayerDataStorage { let path = self.get_player_data_path(uuid); - // Update cache if caching is enabled - if self.cache_enabled { - let mut cache = self.cache.lock().await; - cache.insert(*uuid, (data.clone(), Instant::now())); - }; - // Run disk I/O in a separate tokio task let uuid_copy = *uuid; - tokio::spawn(async move { + let data_clone = data; + + match tokio::spawn(async move { // Ensure parent directory exists if let Some(parent) = path.parent() { if let Err(e) = create_dir_all(parent) { @@ -182,100 +150,41 @@ impl PlayerDataStorage { uuid_copy, e ); - return; + return Err(PlayerDataError::Io(e)); } } - // Compress the NBT data - let compressed = match pumpkin_nbt::nbt_compress::write_gzip_compound_tag(&data) { - Ok(compressed) => compressed, - Err(e) => { - log::error!("Failed to compress player data for {}: {}", uuid_copy, e); - return; - } - }; - - // Save to disk + // Create the file and write directly with GZip compression match File::create(&path) { - Ok(mut file) => { - if let Err(e) = file.write_all(&compressed) { - log::error!("Failed to write player data for {}: {}", uuid_copy, e); + Ok(file) => { + if let Err(e) = + pumpkin_nbt::nbt_compress::write_gzip_compound_tag(&data_clone, file) + { + log::error!( + "Failed to write compressed player data for {}: {}", + uuid_copy, + e + ); + Err(PlayerDataError::Nbt(e.to_string())) } else { log::debug!("Saved player data for {} to disk", uuid_copy); + Ok(()) } } Err(e) => { log::error!("Failed to create player data file for {}: {}", uuid_copy, e); + Err(PlayerDataError::Io(e)) } } - }); - Ok(()) - } - - /// Caches player data on disconnect to avoid loading from disk on rejoin. - /// - /// This function is used when a player disconnects, to temporarily cache their - /// data in memory with an expiration timestamp. - /// - /// # Arguments - /// - /// * `uuid` - The UUID of the player who disconnected. - /// * `data` - The NBT compound data to cache. - pub async fn cache_on_disconnect(&self, uuid: &Uuid, data: NbtCompound) { - // Skip if caching is disabled - if !self.cache_enabled { - return; - } - - // Clone the data to avoid holding locks during complex operations - let data_clone = data.clone(); - - // Use a scope to limit the lock duration + }) + .await { - let mut cache = self.cache.lock().await; - - // Check if we need to remove an entry to stay under max_cache_entries - if cache.len() >= self.max_cache_entries && !cache.contains_key(uuid) { - // Find the oldest entry - if let Some(oldest_uuid) = cache - .iter() - .min_by_key(|(_, (_, timestamp))| *timestamp) - .map(|(uuid, _)| *uuid) - { - cache.remove(&oldest_uuid); - } + Ok(result) => result, + Err(e) => { + log::error!("Task panicked while saving player data for {}: {}", uuid, e); + Err(PlayerDataError::Nbt(format!("Task join error: {e}"))) } - - // Insert the new entry - cache.insert(*uuid, (data_clone, Instant::now())); - }; - - log::debug!("Cached player data for {} on disconnect", uuid); - } - - /// Removes expired player data from the cache. - /// - /// This function should be called periodically to clean up cached player data - /// that has exceeded its expiration time. - pub async fn clean_expired_cache(&self) { - if !self.cache_enabled { - return; - } - - let mut cache = self.cache.lock().await; - let now = Instant::now(); - let expired: Vec = cache - .iter() - .filter(|(_, (_, timestamp))| now.duration_since(*timestamp) > self.cache_expiration) - .map(|(uuid, _)| *uuid) - .collect(); - - for uuid in expired { - cache.remove(&uuid); } - - // Release lock before waiting for tasks - drop(cache); } /// Loads player data and applies it to a player. @@ -300,11 +209,6 @@ impl PlayerDataStorage { player.read_nbt(&mut data).await; Ok(()) } - Err(PlayerDataError::NotFound(_)) => { - // For new players, just continue with default data - log::debug!("Creating new player data for {}", uuid); - Ok(()) - } Err(e) => { if self.save_enabled { // Only log as error if player data saving is enabled @@ -348,25 +252,16 @@ impl PlayerDataStorage { pub struct ServerPlayerData { storage: Arc, save_interval: Duration, - last_cleanup: Mutex, - cleanup_interval: Duration, - last_save: Mutex, + last_save: AtomicCell, } impl ServerPlayerData { /// Creates a new `ServerPlayerData` with specified configuration. - pub fn new( - data_path: impl Into, - cache_expiration: Duration, - save_interval: Duration, - cleanup_interval: Duration, - ) -> Self { + pub fn new(data_path: impl Into, save_interval: Duration) -> Self { Self { - storage: Arc::new(PlayerDataStorage::new(data_path, cache_expiration)), + storage: Arc::new(PlayerDataStorage::new(data_path)), save_interval, - last_cleanup: Mutex::new(Instant::now()), - cleanup_interval, - last_save: Mutex::new(Instant::now()), + last_save: AtomicCell::new(Instant::now()), } } @@ -400,12 +295,7 @@ impl ServerPlayerData { let mut nbt = NbtCompound::new(); player.write_nbt(&mut nbt).await; - // First cache it if caching is enabled - self.storage - .cache_on_disconnect(&player.gameprofile.id, nbt.clone()) - .await; - - // Then save to disk + // Save to disk self.storage .save_player_data(&player.gameprofile.id, nbt) .await?; @@ -420,28 +310,12 @@ impl ServerPlayerData { pub async fn tick(&self, server: &Server) -> Result<(), PlayerDataError> { let now = Instant::now(); - // Check if cleanup is needed - { - let mut last_cleanup = self.last_cleanup.lock().await; - if now.duration_since(*last_cleanup) >= self.cleanup_interval { - self.storage.clean_expired_cache().await; - *last_cleanup = now; - } - } - // Only save players periodically based on save_interval - let should_save = { - let mut last_save = self.last_save.lock().await; - let should_save = now.duration_since(*last_save) >= self.save_interval; - - if should_save { - *last_save = now; - } - - should_save - }; + let last_save = self.last_save.load(); + let should_save = now.duration_since(last_save) >= self.save_interval; if should_save && self.storage.save_enabled { + self.last_save.store(now); // Save all online players periodically across all worlds for world in server.worlds.read().await.iter() { let players = world.players.read().await; diff --git a/pumpkin/src/error.rs b/pumpkin/src/error.rs index c0f65ed7e..396e2952f 100644 --- a/pumpkin/src/error.rs +++ b/pumpkin/src/error.rs @@ -80,7 +80,6 @@ impl PumpkinError for PlayerDataError { match self { Self::Io(err) => Some(format!("Failed to load player data: {err}")), Self::Nbt(err) => Some(format!("Failed to parse player data: {err}")), - Self::NotFound(uuid) => Some(format!("Player data not found for UUID: {uuid}")), } } } diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index 0189a70c8..2c98324d0 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -104,6 +104,13 @@ impl Server { DimensionType::Overworld, ); + // Spawn chunks are never unloaded + for chunk in Self::spawn_chunks() { + world.level.mark_chunk_as_newly_watched(chunk); + } + + let world_name = world.level.level_info.level_name.clone(); + Self { cached_registry: Registry::get_synced(), open_containers: RwLock::new(HashMap::new()), @@ -128,10 +135,8 @@ impl Server { gamemode: BASIC_CONFIG.default_gamemode, }), player_data_storage: ServerPlayerData::new( - "./world/playerdata", // TODO: handle world name in config - Duration::from_secs(3600), // TODO: handle cache expiration in config - Duration::from_secs(300), // TODO: handle save interval in config - Duration::from_secs(600), // TODO: handle cleanup interval in config + format!("./{world_name}/playerdata"), + Duration::from_secs(ADVANCED_CONFIG.player_data.save_player_cron_interval), ), } } @@ -187,7 +192,6 @@ impl Server { .handle_player_join(&mut player) .await { - // This should never happen now with the updated code that always returns Ok() log::error!("Unexpected error loading player data: {}", e); } From 0c3a14252386a000caa7cd1c7468d03f49fcc555 Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Sat, 1 Mar 2025 15:06:33 +0100 Subject: [PATCH 03/12] fix(player_data): implement NBTStorage for hunger data --- pumpkin/src/entity/hunger.rs | 26 ++++++++++++++++++++++++-- pumpkin/src/entity/player.rs | 21 ++------------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/pumpkin/src/entity/hunger.rs b/pumpkin/src/entity/hunger.rs index b0fcda828..3aea69ecd 100644 --- a/pumpkin/src/entity/hunger.rs +++ b/pumpkin/src/entity/hunger.rs @@ -1,7 +1,8 @@ +use super::{EntityBase, NBTStorage, player::Player}; +use async_trait::async_trait; use crossbeam::atomic::AtomicCell; use pumpkin_data::damage::DamageType; - -use super::{EntityBase, player::Player}; +use pumpkin_nbt::compound::NbtCompound; pub struct HungerManager { /// The current hunger level. @@ -63,3 +64,24 @@ impl HungerManager { .store((self.exhaustion.load() + exhaustion).min(40.0)); } } + +#[async_trait] +impl NBTStorage for HungerManager { + async fn write_nbt(&self, nbt: &mut NbtCompound) { + nbt.put_int("foodLevel", self.level.load() as i32); + nbt.put_float("foodSaturationLevel", self.saturation.load()); + nbt.put_float("foodExhaustionLevel", self.exhaustion.load()); + nbt.put_int("foodTickTimer", self.tick_timer.load() as i32); + } + + async fn read_nbt(&mut self, nbt: &mut NbtCompound) { + self.level + .store(nbt.get_int("foodLevel").unwrap_or(20) as u32); + self.saturation + .store(nbt.get_float("foodSaturationLevel").unwrap_or(5.0)); + self.exhaustion + .store(nbt.get_float("foodExhaustionLevel").unwrap_or(0.0)); + self.tick_timer + .store(nbt.get_int("foodTickTimer").unwrap_or(0) as u32); + } +} diff --git a/pumpkin/src/entity/player.rs b/pumpkin/src/entity/player.rs index f2b3d6863..2a2d76426 100644 --- a/pumpkin/src/entity/player.rs +++ b/pumpkin/src/entity/player.rs @@ -1401,13 +1401,7 @@ impl NBTStorage for Player { nbt.put_byte("playerGameType", self.gamemode.load() as i8); // Store food level, saturation, exhaustion, and tick timer - nbt.put_int("foodLevel", self.hunger_manager.level.load() as i32); - nbt.put_float("foodSaturationLevel", self.hunger_manager.saturation.load()); - nbt.put_float("foodExhaustionLevel", self.hunger_manager.exhaustion.load()); - nbt.put_int( - "foodTickTimer", - self.hunger_manager.tick_timer.load() as i32, - ); + self.hunger_manager.write_nbt(nbt).await; } async fn read_nbt(&mut self, nbt: &mut NbtCompound) { @@ -1422,18 +1416,7 @@ impl NBTStorage for Player { ); // Load food level, saturation, exhaustion, and tick timer - self.hunger_manager - .level - .store(nbt.get_int("foodLevel").unwrap_or(20) as u32); - self.hunger_manager - .saturation - .store(nbt.get_float("foodSaturationLevel").unwrap_or(5.0)); - self.hunger_manager - .exhaustion - .store(nbt.get_float("foodExhaustionLevel").unwrap_or(0.0)); - self.hunger_manager - .tick_timer - .store(nbt.get_int("foodTickTimer").unwrap_or(0) as u32); + self.hunger_manager.read_nbt(nbt).await; // Load from total XP let total_exp = nbt.get_int("XpTotal").unwrap_or(0); From e85ecf1f3bc2c25403a7e8665a26809ee8f9b237 Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Sun, 2 Mar 2025 02:26:19 +0100 Subject: [PATCH 04/12] feat(player-data): allow setting world name in config --- pumpkin-config/src/lib.rs | 10 ++++++++++ pumpkin-nbt/Cargo.toml | 3 ++- pumpkin-nbt/src/nbt_compress.rs | 31 ++++++++++++------------------- pumpkin/src/server/mod.rs | 10 ++++------ 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/pumpkin-config/src/lib.rs b/pumpkin-config/src/lib.rs index 58cd12bb6..122594b2c 100644 --- a/pumpkin-config/src/lib.rs +++ b/pumpkin-config/src/lib.rs @@ -4,6 +4,7 @@ use logging::LoggingConfig; use pumpkin_util::{Difficulty, GameMode, PermissionLvl}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::path::PathBuf; use std::{ env, fs, net::{Ipv4Addr, SocketAddr}, @@ -136,6 +137,8 @@ pub struct BasicConfiguration { pub use_favicon: bool, /// Path to server favicon pub favicon_path: String, + /// The default level name + pub default_level_name: String, } impl Default for BasicConfiguration { @@ -159,10 +162,17 @@ impl Default for BasicConfiguration { scrub_ips: true, use_favicon: true, favicon_path: "icon.png".to_string(), + default_level_name: "world".to_string(), } } } +impl BasicConfiguration { + pub fn get_world_path(&self) -> PathBuf { + format!("./{}", self.default_level_name).parse().unwrap() + } +} + trait LoadConfiguration { fn load(exec_dir: &Path) -> Self where diff --git a/pumpkin-nbt/Cargo.toml b/pumpkin-nbt/Cargo.toml index 7fcf22a7d..a402aa7f3 100644 --- a/pumpkin-nbt/Cargo.toml +++ b/pumpkin-nbt/Cargo.toml @@ -9,4 +9,5 @@ thiserror.workspace = true bytes.workspace = true cesu8 = "1.1" -flate2 = "1.1" \ No newline at end of file +flate2 = "1.1" +tempfile = "3.17.1" \ No newline at end of file diff --git a/pumpkin-nbt/src/nbt_compress.rs b/pumpkin-nbt/src/nbt_compress.rs index 15f0eff52..69afdb700 100644 --- a/pumpkin-nbt/src/nbt_compress.rs +++ b/pumpkin-nbt/src/nbt_compress.rs @@ -119,6 +119,7 @@ mod tests { }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; + use std::fs::File; use std::io::Cursor; #[test] @@ -353,29 +354,21 @@ mod tests { #[test] fn test_direct_file_io() { - use std::fs::File; - use std::path::Path; + use tempfile::tempdir; - let mut compound = NbtCompound::new(); - compound.put_int("test_value", 12345); + let temp_dir = tempdir().expect("Failed to create temporary directory"); + let file_path = temp_dir.path().join("test_compound.dat"); - // Create a temporary file path - let path = Path::new("test_nbt_file.dat"); + let mut compound = NbtCompound::new(); + compound.put_int("test_value", 42); - // Write to file directly - { - let file = File::create(path).expect("Failed to create test file"); - write_gzip_compound_tag(&compound, file).expect("Failed to write NBT to file"); - } + let file = File::create(&file_path).expect("Failed to create temp file"); + write_gzip_compound_tag(&compound, file).expect("Failed to write compound to file"); - // Read from file directly - { - let file = File::open(path).expect("Failed to open test file"); - let read_compound = read_gzip_compound_tag(file).expect("Failed to read NBT from file"); - assert_eq!(read_compound.get_int("test_value"), Some(12345)); - } + let file = File::open(&file_path).expect("Failed to open temp file"); + let read_compound = + read_gzip_compound_tag(file).expect("Failed to read compound from file"); - // Clean up - std::fs::remove_file(path).expect("Failed to remove test file"); + assert_eq!(read_compound.get_int("test_value"), Some(42)); } } diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index 2c98324d0..c72ab4c0c 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -95,12 +95,10 @@ impl Server { // First register the default commands. After that, plugins can put in their own. let command_dispatcher = RwLock::new(default_dispatcher()); + let world_path = BASIC_CONFIG.get_world_path(); let world = World::load( - Dimension::OverWorld.into_level( - // TODO: load form config - "./world".parse().unwrap(), - ), + Dimension::OverWorld.into_level(world_path.clone()), DimensionType::Overworld, ); @@ -109,7 +107,7 @@ impl Server { world.level.mark_chunk_as_newly_watched(chunk); } - let world_name = world.level.level_info.level_name.clone(); + let world_name = world_path.file_name().unwrap().to_str().unwrap(); Self { cached_registry: Registry::get_synced(), @@ -135,7 +133,7 @@ impl Server { gamemode: BASIC_CONFIG.default_gamemode, }), player_data_storage: ServerPlayerData::new( - format!("./{world_name}/playerdata"), + format!("{world_name}/playerdata"), Duration::from_secs(ADVANCED_CONFIG.player_data.save_player_cron_interval), ), } From 60a000e18fc2934802240541ca1694c2f6ef881a Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Sun, 2 Mar 2025 02:37:04 +0100 Subject: [PATCH 05/12] fix(player-data): use correct method to retrieve world_name --- pumpkin/src/server/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index c72ab4c0c..34e2bc79d 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -107,7 +107,7 @@ impl Server { world.level.mark_chunk_as_newly_watched(chunk); } - let world_name = world_path.file_name().unwrap().to_str().unwrap(); + let world_name = world_path.to_str().unwrap(); Self { cached_registry: Registry::get_synced(), From 840f82731c00774c30686f057372f109d11aa944 Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Thu, 6 Mar 2025 17:26:01 +0100 Subject: [PATCH 06/12] feat(player-data): move PlayerDataStorage to pumpkin-world --- pumpkin-world/Cargo.toml | 1 + pumpkin-world/src/data/mod.rs | 1 + pumpkin-world/src/data/player_data.rs | 179 ++++++++++++ pumpkin-world/src/lib.rs | 1 + pumpkin/src/data/mod.rs | 2 +- pumpkin/src/data/player_data.rs | 366 ------------------------- pumpkin/src/data/player_server_data.rs | 188 +++++++++++++ pumpkin/src/error.rs | 2 +- pumpkin/src/server/mod.rs | 2 +- 9 files changed, 373 insertions(+), 369 deletions(-) create mode 100644 pumpkin-world/src/data/mod.rs create mode 100644 pumpkin-world/src/data/player_data.rs delete mode 100644 pumpkin/src/data/player_data.rs create mode 100644 pumpkin/src/data/player_server_data.rs diff --git a/pumpkin-world/Cargo.toml b/pumpkin-world/Cargo.toml index d66c5ed60..7c37447dd 100644 --- a/pumpkin-world/Cargo.toml +++ b/pumpkin-world/Cargo.toml @@ -20,6 +20,7 @@ bytes.workspace = true tokio.workspace = true rayon.workspace = true derive_more.workspace = true +uuid.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/pumpkin-world/src/data/mod.rs b/pumpkin-world/src/data/mod.rs new file mode 100644 index 000000000..159924613 --- /dev/null +++ b/pumpkin-world/src/data/mod.rs @@ -0,0 +1 @@ +pub mod player_data; diff --git a/pumpkin-world/src/data/player_data.rs b/pumpkin-world/src/data/player_data.rs new file mode 100644 index 000000000..fc9fa06c2 --- /dev/null +++ b/pumpkin-world/src/data/player_data.rs @@ -0,0 +1,179 @@ +use pumpkin_config::ADVANCED_CONFIG; +use pumpkin_nbt::compound::NbtCompound; +use std::fs::{File, create_dir_all}; +use std::io; +use std::path::PathBuf; +use uuid::Uuid; + +/// Manages the storage and retrieval of player data from disk and memory cache. +/// +/// This struct provides functions to load and save player data to/from NBT files, +/// with a memory cache to handle player disconnections temporarily. +pub struct PlayerDataStorage { + /// Path to the directory where player data is stored + data_path: PathBuf, + /// Whether player data saving is enabled + pub save_enabled: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum PlayerDataError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + #[error("NBT error: {0}")] + Nbt(String), +} + +impl PlayerDataStorage { + /// Creates a new `PlayerDataStorage` with the specified data path and cache expiration time. + pub fn new(data_path: impl Into) -> Self { + let path = data_path.into(); + if !path.exists() { + if let Err(e) = create_dir_all(&path) { + log::error!( + "Failed to create player data directory at {:?}: {}", + path, + e + ); + } + } + + let config = &ADVANCED_CONFIG.player_data; + + Self { + data_path: path, + save_enabled: config.save_player_data, + } + } + + /// Returns the path for a player's data file based on their UUID. + fn get_player_data_path(&self, uuid: &Uuid) -> PathBuf { + self.data_path.join(format!("{uuid}.dat")) + } + + /// Loads player data from NBT file or cache. + /// + /// This function first checks if player data exists in the cache. + /// If not, it attempts to load the data from a .dat file on disk. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the player to load data for. + /// + /// # Returns + /// + /// A Result containing either the player's NBT data or an error. + pub async fn load_player_data(&self, uuid: &Uuid) -> Result { + // If player data saving is disabled, return empty data + if !self.save_enabled { + return Ok(NbtCompound::new()); + } + + // If not in cache, load from disk + let path = self.get_player_data_path(uuid); + if !path.exists() { + log::debug!("No player data file found for {}", uuid); + return Ok(NbtCompound::new()); + } + + // Offload file I/O to a separate tokio task + let uuid_copy = *uuid; + let nbt = tokio::task::spawn_blocking(move || -> Result { + match File::open(&path) { + Ok(file) => { + // Read directly from the file with GZip decompression + pumpkin_nbt::nbt_compress::read_gzip_compound_tag(file) + .map_err(|e| PlayerDataError::Nbt(e.to_string())) + } + Err(e) => Err(PlayerDataError::Io(e)), + } + }) + .await + .unwrap_or_else(|e| { + log::error!( + "Task error when loading player data for {}: {}", + uuid_copy, + e + ); + Err(PlayerDataError::Nbt(format!("Task join error: {e}"))) + })?; + + log::debug!("Loaded player data for {} from disk", uuid); + Ok(nbt) + } + + /// Saves player data to NBT file and updates cache. + /// + /// This function saves the player's data to a .dat file on disk and also + /// updates the in-memory cache with the latest data. + /// + /// # Arguments + /// + /// * `uuid` - The UUID of the player to save data for. + /// * `data` - The NBT compound data to save. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn save_player_data( + &self, + uuid: &Uuid, + data: NbtCompound, + ) -> Result<(), PlayerDataError> { + // Skip saving if disabled in config + if !self.save_enabled { + return Ok(()); + } + + let path = self.get_player_data_path(uuid); + + // Run disk I/O in a separate tokio task + let uuid_copy = *uuid; + let data_clone = data; + + match tokio::spawn(async move { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + if let Err(e) = create_dir_all(parent) { + log::error!( + "Failed to create player data directory for {}: {}", + uuid_copy, + e + ); + return Err(PlayerDataError::Io(e)); + } + } + + // Create the file and write directly with GZip compression + match File::create(&path) { + Ok(file) => { + if let Err(e) = + pumpkin_nbt::nbt_compress::write_gzip_compound_tag(&data_clone, file) + { + log::error!( + "Failed to write compressed player data for {}: {}", + uuid_copy, + e + ); + Err(PlayerDataError::Nbt(e.to_string())) + } else { + log::debug!("Saved player data for {} to disk", uuid_copy); + Ok(()) + } + } + Err(e) => { + log::error!("Failed to create player data file for {}: {}", uuid_copy, e); + Err(PlayerDataError::Io(e)) + } + } + }) + .await + { + Ok(result) => result, + Err(e) => { + log::error!("Task panicked while saving player data for {}: {}", uuid, e); + Err(PlayerDataError::Nbt(format!("Task join error: {e}"))) + } + } + } +} diff --git a/pumpkin-world/src/lib.rs b/pumpkin-world/src/lib.rs index 9d39cfd0c..bb820aa01 100644 --- a/pumpkin-world/src/lib.rs +++ b/pumpkin-world/src/lib.rs @@ -5,6 +5,7 @@ pub mod block; pub mod chunk; pub mod coordinates; pub mod cylindrical_chunk_iterator; +pub mod data; pub mod dimension; mod generation; pub mod item; diff --git a/pumpkin/src/data/mod.rs b/pumpkin/src/data/mod.rs index 8aab6cc35..74d699922 100644 --- a/pumpkin/src/data/mod.rs +++ b/pumpkin/src/data/mod.rs @@ -9,7 +9,7 @@ pub mod op_data; pub mod banlist_serializer; pub mod banned_ip_data; pub mod banned_player_data; -pub mod player_data; +pub mod player_server_data; pub trait LoadJSONConfiguration { #[must_use] diff --git a/pumpkin/src/data/player_data.rs b/pumpkin/src/data/player_data.rs deleted file mode 100644 index f12386b27..000000000 --- a/pumpkin/src/data/player_data.rs +++ /dev/null @@ -1,366 +0,0 @@ -use crossbeam::atomic::AtomicCell; -use pumpkin_config::ADVANCED_CONFIG; -use pumpkin_nbt::compound::NbtCompound; -use std::sync::Arc; -use std::{ - fs::{File, create_dir_all}, - io, - path::PathBuf, - time::{Duration, Instant}, -}; -use uuid::Uuid; - -use crate::{ - entity::{NBTStorage, player::Player}, - server::Server, -}; - -/// Manages the storage and retrieval of player data from disk and memory cache. -/// -/// This struct provides functions to load and save player data to/from NBT files, -/// with a memory cache to handle player disconnections temporarily. -pub struct PlayerDataStorage { - /// Path to the directory where player data is stored - data_path: PathBuf, - /// Whether player data saving is enabled - save_enabled: bool, -} - -#[derive(Debug, thiserror::Error)] -pub enum PlayerDataError { - #[error("IO error: {0}")] - Io(#[from] io::Error), - #[error("NBT error: {0}")] - Nbt(String), -} - -impl PlayerDataStorage { - /// Creates a new `PlayerDataStorage` with the specified data path and cache expiration time. - pub fn new(data_path: impl Into) -> Self { - let path = data_path.into(); - if !path.exists() { - if let Err(e) = create_dir_all(&path) { - log::error!( - "Failed to create player data directory at {:?}: {}", - path, - e - ); - } - } - - let config = &ADVANCED_CONFIG.player_data; - - Self { - data_path: path, - save_enabled: config.save_player_data, - } - } - - /// Returns the path for a player's data file based on their UUID. - fn get_player_data_path(&self, uuid: &Uuid) -> PathBuf { - self.data_path.join(format!("{uuid}.dat")) - } - - /// Loads player data from NBT file or cache. - /// - /// This function first checks if player data exists in the cache. - /// If not, it attempts to load the data from a .dat file on disk. - /// - /// # Arguments - /// - /// * `uuid` - The UUID of the player to load data for. - /// - /// # Returns - /// - /// A Result containing either the player's NBT data or an error. - pub async fn load_player_data(&self, uuid: &Uuid) -> Result { - // If player data saving is disabled, return empty data - if !self.save_enabled { - return Ok(NbtCompound::new()); - } - - // If not in cache, load from disk - let path = self.get_player_data_path(uuid); - if !path.exists() { - log::debug!("No player data file found for {}", uuid); - return Ok(NbtCompound::new()); - } - - // Offload file I/O to a separate tokio task - let uuid_copy = *uuid; - let nbt = tokio::task::spawn_blocking(move || -> Result { - match File::open(&path) { - Ok(file) => { - // Read directly from the file with GZip decompression - pumpkin_nbt::nbt_compress::read_gzip_compound_tag(file) - .map_err(|e| PlayerDataError::Nbt(e.to_string())) - } - Err(e) => Err(PlayerDataError::Io(e)), - } - }) - .await - .unwrap_or_else(|e| { - log::error!( - "Task error when loading player data for {}: {}", - uuid_copy, - e - ); - Err(PlayerDataError::Nbt(format!("Task join error: {e}"))) - })?; - - log::debug!("Loaded player data for {} from disk", uuid); - Ok(nbt) - } - - /// Saves player data to NBT file and updates cache. - /// - /// This function saves the player's data to a .dat file on disk and also - /// updates the in-memory cache with the latest data. - /// - /// # Arguments - /// - /// * `uuid` - The UUID of the player to save data for. - /// * `data` - The NBT compound data to save. - /// - /// # Returns - /// - /// A Result indicating success or the error that occurred. - pub async fn save_player_data( - &self, - uuid: &Uuid, - data: NbtCompound, - ) -> Result<(), PlayerDataError> { - // Skip saving if disabled in config - if !self.save_enabled { - return Ok(()); - } - - let path = self.get_player_data_path(uuid); - - // Run disk I/O in a separate tokio task - let uuid_copy = *uuid; - let data_clone = data; - - match tokio::spawn(async move { - // Ensure parent directory exists - if let Some(parent) = path.parent() { - if let Err(e) = create_dir_all(parent) { - log::error!( - "Failed to create player data directory for {}: {}", - uuid_copy, - e - ); - return Err(PlayerDataError::Io(e)); - } - } - - // Create the file and write directly with GZip compression - match File::create(&path) { - Ok(file) => { - if let Err(e) = - pumpkin_nbt::nbt_compress::write_gzip_compound_tag(&data_clone, file) - { - log::error!( - "Failed to write compressed player data for {}: {}", - uuid_copy, - e - ); - Err(PlayerDataError::Nbt(e.to_string())) - } else { - log::debug!("Saved player data for {} to disk", uuid_copy); - Ok(()) - } - } - Err(e) => { - log::error!("Failed to create player data file for {}: {}", uuid_copy, e); - Err(PlayerDataError::Io(e)) - } - } - }) - .await - { - Ok(result) => result, - Err(e) => { - log::error!("Task panicked while saving player data for {}: {}", uuid, e); - Err(PlayerDataError::Nbt(format!("Task join error: {e}"))) - } - } - } - - /// Loads player data and applies it to a player. - /// - /// This function loads a player's data and applies it to their Player instance. - /// For new players, it creates default data without errors. - /// - /// # Arguments - /// - /// * `player` - The player to load data for and apply to. - /// - /// # Returns - /// - /// A Result indicating success or the error that occurred. - pub async fn load_and_apply_data_to_player( - &self, - player: &mut Player, - ) -> Result<(), PlayerDataError> { - let uuid = &player.gameprofile.id; - match self.load_player_data(uuid).await { - Ok(mut data) => { - player.read_nbt(&mut data).await; - Ok(()) - } - Err(e) => { - if self.save_enabled { - // Only log as error if player data saving is enabled - log::error!("Error loading player data for {}: {}", uuid, e); - } else { - // Otherwise just log as info since it's expected - log::debug!("Not loading player data for {} (saving disabled)", uuid); - } - // Continue with default data even if there's an error - Ok(()) - } - } - } - - /// Extracts and saves data from a player. - /// - /// This function extracts NBT data from a player and saves it to disk. - /// - /// # Arguments - /// - /// * `player` - The player to extract and save data for. - /// - /// # Returns - /// - /// A Result indicating success or the error that occurred. - pub async fn extract_data_and_save_player( - &self, - player: &Player, - ) -> Result<(), PlayerDataError> { - let uuid = &player.gameprofile.id; - let mut nbt = NbtCompound::new(); - player.write_nbt(&mut nbt).await; - self.save_player_data(uuid, nbt).await - } -} - -/// Helper for managing player data in the server context. -/// -/// This struct provides server-wide access to the `PlayerDataStorage` and -/// convenience methods for player handling. -pub struct ServerPlayerData { - storage: Arc, - save_interval: Duration, - last_save: AtomicCell, -} - -impl ServerPlayerData { - /// Creates a new `ServerPlayerData` with specified configuration. - pub fn new(data_path: impl Into, save_interval: Duration) -> Self { - Self { - storage: Arc::new(PlayerDataStorage::new(data_path)), - save_interval, - last_save: AtomicCell::new(Instant::now()), - } - } - - /// Handles a player joining the server. - /// - /// This function loads player data and applies it to a newly joined player. - /// - /// # Arguments - /// - /// * `player` - The player who joined. - /// - /// # Returns - /// - /// A Result indicating success or the error that occurred. - pub async fn handle_player_join(&self, player: &mut Player) -> Result<(), PlayerDataError> { - self.storage.load_and_apply_data_to_player(player).await - } - - /// Handles a player leaving the server. - /// - /// This function saves player data when they disconnect. - /// - /// # Arguments - /// - /// * `player` - The player who left. - /// - /// # Returns - /// - /// A Result indicating success or the error that occurred. - pub async fn handle_player_leave(&self, player: &Player) -> Result<(), PlayerDataError> { - let mut nbt = NbtCompound::new(); - player.write_nbt(&mut nbt).await; - - // Save to disk - self.storage - .save_player_data(&player.gameprofile.id, nbt) - .await?; - - Ok(()) - } - - /// Performs periodic maintenance tasks. - /// - /// This function should be called regularly to save player data and clean - /// expired cache entries. - pub async fn tick(&self, server: &Server) -> Result<(), PlayerDataError> { - let now = Instant::now(); - - // Only save players periodically based on save_interval - let last_save = self.last_save.load(); - let should_save = now.duration_since(last_save) >= self.save_interval; - - if should_save && self.storage.save_enabled { - self.last_save.store(now); - // Save all online players periodically across all worlds - for world in server.worlds.read().await.iter() { - let players = world.players.read().await; - for player in players.values() { - let mut nbt = NbtCompound::new(); - player.write_nbt(&mut nbt).await; - - // Save to disk periodically to prevent data loss on server crash - if let Err(e) = self - .storage - .save_player_data(&player.gameprofile.id, nbt) - .await - { - log::error!( - "Failed to save player data for {}: {}", - player.gameprofile.id, - e - ); - } - } - } - - log::debug!("Periodic player data save completed"); - } - - Ok(()) - } - - /// Saves all players' data immediately. - /// - /// This function immediately saves all online players' data to disk. - /// Useful for server shutdown or backup operations. - pub async fn save_all_players(&self, server: &Server) -> Result<(), PlayerDataError> { - let mut total_players = 0; - - // Save players from all worlds - for world in server.worlds.read().await.iter() { - let players = world.players.read().await; - for player in players.values() { - self.storage.extract_data_and_save_player(player).await?; - total_players += 1; - } - } - - log::debug!("Saved data for {} online players", total_players); - Ok(()) - } -} diff --git a/pumpkin/src/data/player_server_data.rs b/pumpkin/src/data/player_server_data.rs new file mode 100644 index 000000000..c76762a23 --- /dev/null +++ b/pumpkin/src/data/player_server_data.rs @@ -0,0 +1,188 @@ +use crate::{ + entity::{NBTStorage, player::Player}, + server::Server, +}; +use crossbeam::atomic::AtomicCell; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_world::data::player_data::{PlayerDataError, PlayerDataStorage}; +use std::sync::Arc; +use std::{ + path::PathBuf, + time::{Duration, Instant}, +}; +/// Helper for managing player data in the server context. +/// +/// This struct provides server-wide access to the `PlayerDataStorage` and +/// convenience methods for player handling. +pub struct ServerPlayerData { + storage: Arc, + save_interval: Duration, + last_save: AtomicCell, +} + +impl ServerPlayerData { + /// Creates a new `ServerPlayerData` with specified configuration. + pub fn new(data_path: impl Into, save_interval: Duration) -> Self { + Self { + storage: Arc::new(PlayerDataStorage::new(data_path)), + save_interval, + last_save: AtomicCell::new(Instant::now()), + } + } + + /// Handles a player joining the server. + /// + /// This function loads player data and applies it to a newly joined player. + /// + /// # Arguments + /// + /// * `player` - The player who joined. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn handle_player_join(&self, player: &mut Player) -> Result<(), PlayerDataError> { + self.load_and_apply_data_to_player(player).await + } + + /// Handles a player leaving the server. + /// + /// This function saves player data when they disconnect. + /// + /// # Arguments + /// + /// * `player` - The player who left. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn handle_player_leave(&self, player: &Player) -> Result<(), PlayerDataError> { + let mut nbt = NbtCompound::new(); + player.write_nbt(&mut nbt).await; + + // Save to disk + self.storage + .save_player_data(&player.gameprofile.id, nbt) + .await?; + + Ok(()) + } + + /// Performs periodic maintenance tasks. + /// + /// This function should be called regularly to save player data and clean + /// expired cache entries. + pub async fn tick(&self, server: &Server) -> Result<(), PlayerDataError> { + let now = Instant::now(); + + // Only save players periodically based on save_interval + let last_save = self.last_save.load(); + let should_save = now.duration_since(last_save) >= self.save_interval; + + if should_save && self.storage.save_enabled { + self.last_save.store(now); + // Save all online players periodically across all worlds + for world in server.worlds.read().await.iter() { + let players = world.players.read().await; + for player in players.values() { + let mut nbt = NbtCompound::new(); + player.write_nbt(&mut nbt).await; + + // Save to disk periodically to prevent data loss on server crash + if let Err(e) = self + .storage + .save_player_data(&player.gameprofile.id, nbt) + .await + { + log::error!( + "Failed to save player data for {}: {}", + player.gameprofile.id, + e + ); + } + } + } + + log::debug!("Periodic player data save completed"); + } + + Ok(()) + } + + /// Saves all players' data immediately. + /// + /// This function immediately saves all online players' data to disk. + /// Useful for server shutdown or backup operations. + pub async fn save_all_players(&self, server: &Server) -> Result<(), PlayerDataError> { + let mut total_players = 0; + + // Save players from all worlds + for world in server.worlds.read().await.iter() { + let players = world.players.read().await; + for player in players.values() { + self.extract_data_and_save_player(player).await?; + total_players += 1; + } + } + + log::debug!("Saved data for {} online players", total_players); + Ok(()) + } + + /// Loads player data and applies it to a player. + /// + /// This function loads a player's data and applies it to their Player instance. + /// For new players, it creates default data without errors. + /// + /// # Arguments + /// + /// * `player` - The player to load data for and apply to. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn load_and_apply_data_to_player( + &self, + player: &mut Player, + ) -> Result<(), PlayerDataError> { + let uuid = &player.gameprofile.id; + match self.storage.load_player_data(uuid).await { + Ok(mut data) => { + player.read_nbt(&mut data).await; + Ok(()) + } + Err(e) => { + if self.storage.save_enabled { + // Only log as error if player data saving is enabled + log::error!("Error loading player data for {}: {}", uuid, e); + } else { + // Otherwise just log as info since it's expected + log::debug!("Not loading player data for {} (saving disabled)", uuid); + } + // Continue with default data even if there's an error + Ok(()) + } + } + } + + /// Extracts and saves data from a player. + /// + /// This function extracts NBT data from a player and saves it to disk. + /// + /// # Arguments + /// + /// * `player` - The player to extract and save data for. + /// + /// # Returns + /// + /// A Result indicating success or the error that occurred. + pub async fn extract_data_and_save_player( + &self, + player: &Player, + ) -> Result<(), PlayerDataError> { + let uuid = &player.gameprofile.id; + let mut nbt = NbtCompound::new(); + player.write_nbt(&mut nbt).await; + self.storage.save_player_data(uuid, nbt).await + } +} diff --git a/pumpkin/src/error.rs b/pumpkin/src/error.rs index 396e2952f..4d3a15cc4 100644 --- a/pumpkin/src/error.rs +++ b/pumpkin/src/error.rs @@ -1,7 +1,7 @@ -use crate::data::player_data::PlayerDataError; use log::log; use pumpkin_inventory::InventoryError; use pumpkin_protocol::bytebuf::ReadingError; +use pumpkin_world::data::player_data::PlayerDataError; use std::fmt::Display; pub trait PumpkinError: Send + std::error::Error + Display { diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index 34e2bc79d..5e9a61e4f 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -1,7 +1,7 @@ use crate::block::registry::BlockRegistry; use crate::command::commands::default_dispatcher; use crate::command::commands::defaultgamemode::DefaultGamemode; -use crate::data::player_data::ServerPlayerData; +use crate::data::player_server_data::ServerPlayerData; use crate::entity::{Entity, EntityId}; use crate::item::registry::ItemRegistry; use crate::net::EncryptionError; From 3b45a98d28bc2a354e0c06884262b1dcada92a6e Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Thu, 6 Mar 2025 20:35:39 +0100 Subject: [PATCH 07/12] fix(player-data): ci not building --- pumpkin/src/entity/experience_orb.rs | 2 +- pumpkin/src/server/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pumpkin/src/entity/experience_orb.rs b/pumpkin/src/entity/experience_orb.rs index 74e2cf0bd..35cd2e368 100644 --- a/pumpkin/src/entity/experience_orb.rs +++ b/pumpkin/src/entity/experience_orb.rs @@ -84,7 +84,7 @@ impl EntityBase for ExperienceOrbEntity { if *delay == 0 { *delay = 2; player.living_entity.pickup(&self.entity, 1).await; - player.add_experience_points(self.amount as i32).await; + player.add_experience_points(self.amount as i32); // TODO: pickingCount for merging self.entity.remove().await; } diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index 5e9a61e4f..56aaf2776 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -2,7 +2,7 @@ use crate::block::registry::BlockRegistry; use crate::command::commands::default_dispatcher; use crate::command::commands::defaultgamemode::DefaultGamemode; use crate::data::player_server_data::ServerPlayerData; -use crate::entity::{Entity, EntityId}; +use crate::entity::EntityId; use crate::item::registry::ItemRegistry; use crate::net::EncryptionError; use crate::plugin::player::player_login::PlayerLoginEvent; From 485b90c1edc13bc46089a5e7c6b7fff3fa78d27d Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Thu, 6 Mar 2025 20:49:40 +0100 Subject: [PATCH 08/12] fix(player-data): ci not building --- pumpkin/src/server/mod.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index 56aaf2776..f83df9765 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -102,11 +102,6 @@ impl Server { DimensionType::Overworld, ); - // Spawn chunks are never unloaded - for chunk in Self::spawn_chunks() { - world.level.mark_chunk_as_newly_watched(chunk); - } - let world_name = world_path.to_str().unwrap(); Self { From 39e551ed0d23c9f4f5e0f9ca8c3447922a8ef7a5 Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Wed, 19 Mar 2025 00:48:59 +0100 Subject: [PATCH 09/12] feat(player-data): support inventory saving + review fix --- pumpkin-inventory/src/player.rs | 18 ++++ pumpkin-world/src/data/player_data.rs | 114 +++++++-------------- pumpkin-world/src/item/mod.rs | 38 +++++++ pumpkin/src/command/commands/experience.rs | 20 ++-- pumpkin/src/data/player_server_data.rs | 24 +++-- pumpkin/src/entity/experience_orb.rs | 2 +- pumpkin/src/entity/player.rs | 104 +++++++++++++++++-- pumpkin/src/server/mod.rs | 2 +- pumpkin/src/world/mod.rs | 26 +++-- 9 files changed, 233 insertions(+), 115 deletions(-) diff --git a/pumpkin-inventory/src/player.rs b/pumpkin-inventory/src/player.rs index 096efd660..e9ff562f0 100644 --- a/pumpkin-inventory/src/player.rs +++ b/pumpkin-inventory/src/player.rs @@ -275,6 +275,24 @@ impl PlayerInventory { slots.into_boxed_slice() } + pub fn armor_slots(&self) -> Box<[Option<&ItemStack>]> { + self.armor.iter().map(|item| item.as_ref()).collect() + } + + pub fn crafting_slots(&self) -> Box<[Option<&ItemStack>]> { + let mut slots = vec![self.crafting_output.as_ref()]; + slots.extend(self.crafting.iter().map(|c| c.as_ref())); + slots.into_boxed_slice() + } + + pub fn item_slots(&self) -> Box<[Option<&ItemStack>]> { + self.items.iter().map(|item| item.as_ref()).collect() + } + + pub fn offhand_slot(&self) -> Option<&ItemStack> { + self.offhand.as_ref() + } + pub fn iter_items_mut(&mut self) -> IterMut> { self.items.iter_mut() } diff --git a/pumpkin-world/src/data/player_data.rs b/pumpkin-world/src/data/player_data.rs index fc9fa06c2..74032966d 100644 --- a/pumpkin-world/src/data/player_data.rs +++ b/pumpkin-world/src/data/player_data.rs @@ -1,4 +1,4 @@ -use pumpkin_config::ADVANCED_CONFIG; +use pumpkin_config::advanced_config; use pumpkin_nbt::compound::NbtCompound; use std::fs::{File, create_dir_all}; use std::io; @@ -38,11 +38,9 @@ impl PlayerDataStorage { } } - let config = &ADVANCED_CONFIG.player_data; - Self { data_path: path, - save_enabled: config.save_player_data, + save_enabled: advanced_config().player_data.save_player_data, } } @@ -63,43 +61,37 @@ impl PlayerDataStorage { /// # Returns /// /// A Result containing either the player's NBT data or an error. - pub async fn load_player_data(&self, uuid: &Uuid) -> Result { + pub fn load_player_data(&self, uuid: &Uuid) -> Result<(bool, NbtCompound), PlayerDataError> { // If player data saving is disabled, return empty data if !self.save_enabled { - return Ok(NbtCompound::new()); + return Ok((false, NbtCompound::new())); } // If not in cache, load from disk let path = self.get_player_data_path(uuid); if !path.exists() { log::debug!("No player data file found for {}", uuid); - return Ok(NbtCompound::new()); + return Ok((false, NbtCompound::new())); } - // Offload file I/O to a separate tokio task - let uuid_copy = *uuid; - let nbt = tokio::task::spawn_blocking(move || -> Result { - match File::open(&path) { - Ok(file) => { - // Read directly from the file with GZip decompression - pumpkin_nbt::nbt_compress::read_gzip_compound_tag(file) - .map_err(|e| PlayerDataError::Nbt(e.to_string())) - } - Err(e) => Err(PlayerDataError::Io(e)), + let file = match File::open(&path) { + Ok(file) => file, + Err(e) => { + log::error!("Failed to open player data file for {}: {}", uuid, e); + return Err(PlayerDataError::Io(e)); } - }) - .await - .unwrap_or_else(|e| { - log::error!( - "Task error when loading player data for {}: {}", - uuid_copy, - e - ); - Err(PlayerDataError::Nbt(format!("Task join error: {e}"))) - })?; + }; - log::debug!("Loaded player data for {} from disk", uuid); - Ok(nbt) + match pumpkin_nbt::nbt_compress::read_gzip_compound_tag(file) { + Ok(nbt) => { + log::debug!("Loaded player data for {} from disk", uuid); + Ok((true, nbt)) + } + Err(e) => { + log::error!("Failed to read player data for {}: {}", uuid, e); + Err(PlayerDataError::Nbt(e.to_string())) + } + } } /// Saves player data to NBT file and updates cache. @@ -115,11 +107,7 @@ impl PlayerDataStorage { /// # Returns /// /// A Result indicating success or the error that occurred. - pub async fn save_player_data( - &self, - uuid: &Uuid, - data: NbtCompound, - ) -> Result<(), PlayerDataError> { + pub fn save_player_data(&self, uuid: &Uuid, data: NbtCompound) -> Result<(), PlayerDataError> { // Skip saving if disabled in config if !self.save_enabled { return Ok(()); @@ -127,52 +115,28 @@ impl PlayerDataStorage { let path = self.get_player_data_path(uuid); - // Run disk I/O in a separate tokio task - let uuid_copy = *uuid; - let data_clone = data; - - match tokio::spawn(async move { - // Ensure parent directory exists - if let Some(parent) = path.parent() { - if let Err(e) = create_dir_all(parent) { - log::error!( - "Failed to create player data directory for {}: {}", - uuid_copy, - e - ); - return Err(PlayerDataError::Io(e)); - } + // Ensure parent directory exists + if let Some(parent) = path.parent() { + if let Err(e) = create_dir_all(parent) { + log::error!("Failed to create player data directory for {}: {}", uuid, e); + return Err(PlayerDataError::Io(e)); } + } - // Create the file and write directly with GZip compression - match File::create(&path) { - Ok(file) => { - if let Err(e) = - pumpkin_nbt::nbt_compress::write_gzip_compound_tag(&data_clone, file) - { - log::error!( - "Failed to write compressed player data for {}: {}", - uuid_copy, - e - ); - Err(PlayerDataError::Nbt(e.to_string())) - } else { - log::debug!("Saved player data for {} to disk", uuid_copy); - Ok(()) - } - } - Err(e) => { - log::error!("Failed to create player data file for {}: {}", uuid_copy, e); - Err(PlayerDataError::Io(e)) + // Create the file and write directly with GZip compression + match File::create(&path) { + Ok(file) => { + if let Err(e) = pumpkin_nbt::nbt_compress::write_gzip_compound_tag(&data, file) { + log::error!("Failed to write compressed player data for {}: {}", uuid, e); + Err(PlayerDataError::Nbt(e.to_string())) + } else { + log::debug!("Saved player data for {} to disk", uuid); + Ok(()) } } - }) - .await - { - Ok(result) => result, Err(e) => { - log::error!("Task panicked while saving player data for {}: {}", uuid, e); - Err(PlayerDataError::Nbt(format!("Task join error: {e}"))) + log::error!("Failed to create player data file for {}: {}", uuid, e); + Err(PlayerDataError::Io(e)) } } } diff --git a/pumpkin-world/src/item/mod.rs b/pumpkin-world/src/item/mod.rs index de85f7a11..d39433921 100644 --- a/pumpkin-world/src/item/mod.rs +++ b/pumpkin-world/src/item/mod.rs @@ -1,5 +1,6 @@ use pumpkin_data::item::Item; use pumpkin_data::tag::{RegistryKey, get_tag_values}; +use pumpkin_nbt::compound::NbtCompound; mod categories; @@ -100,4 +101,41 @@ impl ItemStack { } false } + + pub fn write_item_stack(&self, compound: &mut NbtCompound) { + // Minecraft 1.21.4 uses "id" as string with namespaced ID (minecraft:diamond_sword) + compound.put_string("id", format!("minecraft:{}", self.item.registry_key)); + compound.put_int("count", self.item_count as i32); + + // Create a tag compound for additional data + let tag = NbtCompound::new(); + + // TODO: Store custom data like enchantments, display name, etc. would go here + + // Store custom data like enchantments, display name, etc. would go here + compound.put_component("components", tag); + } + + pub fn read_item_stack(compound: &NbtCompound) -> Option { + // Get ID, which is a string like "minecraft:diamond_sword" + let full_id = compound.get_string("id")?; + + // Remove the "minecraft:" prefix if present + let registry_key = full_id.strip_prefix("minecraft:").unwrap_or(full_id); + + // Try to get item by registry key + let item = Item::from_registry_key(registry_key)?; + + let count = compound.get_int("count")? as u8; + + // Create the item stack + let item_stack = Self::new(count, item); + + // Process any additional data in the components compound + if let Some(_tag) = compound.get_compound("components") { + // TODO: Process additional components like damage, enchantments, etc. + } + + Some(item_stack) + } } diff --git a/pumpkin/src/command/commands/experience.rs b/pumpkin/src/command/commands/experience.rs index 8158bd116..fe322596f 100644 --- a/pumpkin/src/command/commands/experience.rs +++ b/pumpkin/src/command/commands/experience.rs @@ -166,23 +166,24 @@ impl Executor { } } - fn handle_modify( + async fn handle_modify( &self, target: &Player, amount: i32, exp_type: ExpType, + mode: Mode, ) -> Result<(), &'static str> { match exp_type { ExpType::Levels => { - if self.mode == Mode::Add { - target.add_experience_levels(amount); + if mode == Mode::Add { + target.add_experience_levels(amount).await; } else { - target.set_experience_level(amount, true); + target.set_experience_level(amount, true).await; } } ExpType::Points => { - if self.mode == Mode::Add { - target.add_experience_points(amount); + 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); @@ -192,7 +193,7 @@ impl Executor { return Err("commands.experience.set.points.invalid"); } - target.set_experience_points(amount); + target.set_experience_points(amount).await; } } } @@ -242,7 +243,10 @@ impl CommandExecutor for Executor { } for target in targets { - match self.handle_modify(target, amount, self.exp_type.unwrap()) { + match self + .handle_modify(target, amount, self.exp_type.unwrap(), self.mode) + .await + { Ok(()) => { let msg = Self::get_success_message( self.mode, diff --git a/pumpkin/src/data/player_server_data.rs b/pumpkin/src/data/player_server_data.rs index c76762a23..a3082d523 100644 --- a/pumpkin/src/data/player_server_data.rs +++ b/pumpkin/src/data/player_server_data.rs @@ -61,9 +61,7 @@ impl ServerPlayerData { player.write_nbt(&mut nbt).await; // Save to disk - self.storage - .save_player_data(&player.gameprofile.id, nbt) - .await?; + self.storage.save_player_data(&player.gameprofile.id, nbt)?; Ok(()) } @@ -89,11 +87,7 @@ impl ServerPlayerData { player.write_nbt(&mut nbt).await; // Save to disk periodically to prevent data loss on server crash - if let Err(e) = self - .storage - .save_player_data(&player.gameprofile.id, nbt) - .await - { + if let Err(e) = self.storage.save_player_data(&player.gameprofile.id, nbt) { log::error!( "Failed to save player data for {}: {}", player.gameprofile.id, @@ -146,8 +140,12 @@ impl ServerPlayerData { player: &mut Player, ) -> Result<(), PlayerDataError> { let uuid = &player.gameprofile.id; - match self.storage.load_player_data(uuid).await { - Ok(mut data) => { + match self.storage.load_player_data(uuid) { + Ok((should_load, mut data)) => { + if !should_load { + // No data to load, continue with default data + return Ok(()); + } player.read_nbt(&mut data).await; Ok(()) } @@ -180,9 +178,13 @@ impl ServerPlayerData { &self, player: &Player, ) -> Result<(), PlayerDataError> { + if !self.storage.save_enabled { + return Ok(()); + } + let uuid = &player.gameprofile.id; let mut nbt = NbtCompound::new(); player.write_nbt(&mut nbt).await; - self.storage.save_player_data(uuid, nbt).await + self.storage.save_player_data(uuid, nbt) } } diff --git a/pumpkin/src/entity/experience_orb.rs b/pumpkin/src/entity/experience_orb.rs index 35cd2e368..74e2cf0bd 100644 --- a/pumpkin/src/entity/experience_orb.rs +++ b/pumpkin/src/entity/experience_orb.rs @@ -84,7 +84,7 @@ impl EntityBase for ExperienceOrbEntity { if *delay == 0 { *delay = 2; player.living_entity.pickup(&self.entity, 1).await; - player.add_experience_points(self.amount as i32); + player.add_experience_points(self.amount as i32).await; // TODO: pickingCount for merging self.entity.remove().await; } diff --git a/pumpkin/src/entity/player.rs b/pumpkin/src/entity/player.rs index 2a2d76426..448043d9a 100644 --- a/pumpkin/src/entity/player.rs +++ b/pumpkin/src/entity/player.rs @@ -9,6 +9,7 @@ use std::{ time::{Duration, Instant}, }; +use super::living::LivingEntity; use super::{ Entity, EntityBase, EntityId, NBTStorage, combat::{self, AttackType, player_attack_sound}, @@ -40,9 +41,14 @@ use pumpkin_data::{ particle::Particle, sound::{Sound, SoundCategory}, }; -use pumpkin_inventory::player::PlayerInventory; +use pumpkin_inventory::player::{ + PlayerInventory, SLOT_BOOT, SLOT_CRAFT_INPUT_END, SLOT_CRAFT_INPUT_START, SLOT_HELM, + SLOT_HOTBAR_END, SLOT_INV_START, SLOT_OFFHAND, +}; use pumpkin_macros::send_cancellable; use pumpkin_nbt::compound::NbtCompound; +use pumpkin_nbt::tag::NbtTag; +use pumpkin_protocol::client::play::CSetHeldItem; use pumpkin_protocol::{ RawPacket, ServerPacket, bytebuf::packet::Packet, @@ -85,8 +91,6 @@ use pumpkin_util::{ use pumpkin_world::{cylindrical_chunk_iterator::Cylindrical, item::ItemStack, level::SyncChunk}; use tokio::sync::{Mutex, Notify, RwLock}; -use super::living::LivingEntity; - enum BatchState { Initial, Waiting, @@ -230,6 +234,7 @@ pub struct Player { pub experience_points: AtomicI32, pub experience_pick_up_delay: Mutex, pub chunk_manager: Mutex, + pub has_played_before: AtomicBool, } impl Player { @@ -313,6 +318,7 @@ impl Player { last_sent_health: AtomicI32::new(-1), last_sent_food: AtomicU32::new(0), last_food_saturation: AtomicBool::new(true), + has_played_before: AtomicBool::new(false), } } @@ -1367,7 +1373,8 @@ impl Player { } let progress = new_points as f32 / max_points as f32; - self.set_experience(current_level, progress, new_points).await; + self.set_experience(current_level, progress, new_points) + .await; true } @@ -1381,16 +1388,23 @@ impl Player { let progress = experience::progress_in_level(new_points, new_level); self.set_experience(new_level, progress, new_points).await; } + + /// Send the player's inventory to the client. + pub async fn send_inventory(&self) { + self.set_container_content(None).await; + self.client + .send_packet(&CSetHeldItem::new( + self.inventory.lock().await.selected as i8, + )) + .await; + } } #[async_trait] impl NBTStorage for Player { async fn write_nbt(&self, nbt: &mut NbtCompound) { self.living_entity.write_nbt(nbt).await; - nbt.put_int( - "SelectedItemSlot", - self.inventory.lock().await.selected as i32, - ); + self.inventory.lock().await.write_nbt(nbt).await; self.abilities.lock().await.write_nbt(nbt).await; @@ -1400,14 +1414,18 @@ impl NBTStorage for Player { nbt.put_int("XpTotal", total_exp); nbt.put_byte("playerGameType", self.gamemode.load() as i8); + nbt.put_bool( + "HasPlayedBefore", + self.has_played_before.load(Ordering::Relaxed), + ); + // Store food level, saturation, exhaustion, and tick timer self.hunger_manager.write_nbt(nbt).await; } 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 usize; + self.inventory.lock().await.read_nbt(nbt).await; self.abilities.lock().await.read_nbt(nbt).await; self.gamemode.store( @@ -1415,6 +1433,11 @@ impl NBTStorage for Player { .unwrap_or(GameMode::Survival), ); + self.has_played_before.store( + nbt.get_bool("HasPlayedBefore").unwrap_or(false), + Ordering::Relaxed, + ); + // Load food level, saturation, exhaustion, and tick timer self.hunger_manager.read_nbt(nbt).await; @@ -1428,6 +1451,67 @@ impl NBTStorage for Player { } } +#[async_trait] +impl NBTStorage for PlayerInventory { + async fn write_nbt(&self, nbt: &mut NbtCompound) { + // Save the selected slot (hotbar) + nbt.put_int("SelectedItemSlot", self.selected as i32); + + // Create inventory list with the correct capacity (inventory size) + let mut vec: Vec = Vec::with_capacity(SLOT_OFFHAND); + + // Helper function to add items to the vector + let mut add_item = |slot: usize, stack_ref: Option<&ItemStack>| { + if let Some(stack) = stack_ref { + let mut item_compound = NbtCompound::new(); + item_compound.put_byte("Slot", slot as i8); + stack.write_item_stack(&mut item_compound); + vec.push(NbtTag::Compound(item_compound)); + } + }; + + // Crafting input slots + for slot in SLOT_CRAFT_INPUT_START..=SLOT_CRAFT_INPUT_END { + add_item(slot, self.crafting_slots()[slot - SLOT_CRAFT_INPUT_START]); + } + + // Armor slots + for slot in SLOT_HELM..=SLOT_BOOT { + add_item(slot, self.armor_slots()[slot - SLOT_HELM]); + } + + // Main inventory slots (includes hotbar in the data structure) + for slot in SLOT_INV_START..=SLOT_HOTBAR_END { + add_item(slot, self.item_slots()[slot - SLOT_INV_START]); + } + + // Offhand + add_item(SLOT_OFFHAND, self.offhand_slot()); + + // Save the inventory list + nbt.put("Inventory", NbtTag::List(vec.into_boxed_slice())); + } + + async fn read_nbt(&mut self, nbt: &mut NbtCompound) { + // Read selected hotbar slot + self.selected = nbt.get_int("SelectedItemSlot").unwrap_or(0) as usize; + + // Process inventory list + if let Some(inventory_list) = nbt.get_list("Inventory") { + for tag in inventory_list { + if let Some(item_compound) = tag.extract_compound() { + if let Some(slot_byte) = item_compound.get_byte("Slot") { + let slot = slot_byte as usize; + if let Some(item_stack) = ItemStack::read_item_stack(item_compound) { + let _ = self.set_slot(slot, Some(item_stack), true); + } + } + } + } + } + } +} + #[async_trait] impl EntityBase for Player { async fn damage(&self, amount: f32, damage_type: DamageType) -> bool { diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index f83df9765..2c764beca 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -129,7 +129,7 @@ impl Server { }), player_data_storage: ServerPlayerData::new( format!("{world_name}/playerdata"), - Duration::from_secs(ADVANCED_CONFIG.player_data.save_player_cron_interval), + Duration::from_secs(advanced_config().player_data.save_player_cron_interval), ), } } diff --git a/pumpkin/src/world/mod.rs b/pumpkin/src/world/mod.rs index ea4fce98c..a677f3bcb 100644 --- a/pumpkin/src/world/mod.rs +++ b/pumpkin/src/world/mod.rs @@ -381,18 +381,24 @@ impl World { // Permissions, i.e. the commands a player may use. player.send_permission_lvl_update().await; client_suggestions::send_c_commands_packet(&player, &server.command_dispatcher).await; + // Teleport - let info = &self.level.level_info; - let mut position = Vector3::new(f64::from(info.spawn_x), 120.0, f64::from(info.spawn_z)); - let yaw = info.spawn_angle; - let pitch = 10.0; + let (position, yaw, pitch) = if player.has_played_before.load(Ordering::Relaxed) { + let position = player.position(); + let yaw = player.living_entity.entity.yaw.load(); //info.spawn_angle; + let pitch = player.living_entity.entity.pitch.load(); - // teleport - let position = player.position(); - let velocity = player.living_entity.entity.velocity.load(); + (position, yaw, pitch) + } else { + let info = &self.level.level_info; + let position = Vector3::new(f64::from(info.spawn_x), 120.0, f64::from(info.spawn_z)); + let yaw = info.spawn_angle; + let pitch = 10.0; - let yaw = player.living_entity.entity.yaw.load(); //info.spawn_angle; - let pitch = player.living_entity.entity.pitch.load(); + (position, yaw, pitch) + }; + + let velocity = player.living_entity.entity.velocity.load(); log::debug!("Sending player teleport to {}", player.gameprofile.name); player.request_teleport(position, yaw, pitch).await; @@ -542,7 +548,9 @@ impl World { // } // } + player.has_played_before.store(true, Ordering::Relaxed); player.send_mobs(self).await; + player.send_inventory().await; } pub async fn send_world_info( From 0ee7b82f825e858a820a30fe62a389953ecc4b67 Mon Sep 17 00:00:00 2001 From: Hugo Planque Date: Sun, 23 Mar 2025 02:40:13 +0100 Subject: [PATCH 10/12] fix(player-data): forgot the format --- pumpkin/src/data/player_server_data.rs | 6 ++++-- pumpkin/src/error.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pumpkin/src/data/player_server_data.rs b/pumpkin/src/data/player_server_data.rs index 6034f685c..765a0b183 100644 --- a/pumpkin/src/data/player_server_data.rs +++ b/pumpkin/src/data/player_server_data.rs @@ -1,5 +1,5 @@ use crate::{ - entity::{player::Player, NBTStorage}, + entity::{NBTStorage, player::Player}, server::Server, }; use crossbeam::atomic::AtomicCell; @@ -298,7 +298,9 @@ mod test { let player_data = ServerPlayerData::new(path, save_interval); assert_eq!(player_data.save_interval, save_interval); - assert!(Instant::now().duration_since(player_data.last_save.load()) < Duration::from_secs(1)); + assert!( + Instant::now().duration_since(player_data.last_save.load()) < Duration::from_secs(1) + ); } #[tokio::test] diff --git a/pumpkin/src/error.rs b/pumpkin/src/error.rs index 2c233afc0..bf4d5c1f2 100644 --- a/pumpkin/src/error.rs +++ b/pumpkin/src/error.rs @@ -1,7 +1,7 @@ use log::log; use pumpkin_inventory::InventoryError; -use pumpkin_world::data::player_data::PlayerDataError; use pumpkin_protocol::ser::ReadingError; +use pumpkin_world::data::player_data::PlayerDataError; use std::fmt::Display; pub trait PumpkinError: Send + std::error::Error + Display { From 177d75786864aa4c200b344994fa8c57484ec841 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Mon, 24 Mar 2025 20:12:25 +0100 Subject: [PATCH 11/12] some fixes --- pumpkin/src/entity/mod.rs | 13 +++++++------ pumpkin/src/world/mod.rs | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/pumpkin/src/entity/mod.rs b/pumpkin/src/entity/mod.rs index 394720fb7..333d41de6 100644 --- a/pumpkin/src/entity/mod.rs +++ b/pumpkin/src/entity/mod.rs @@ -30,7 +30,7 @@ use pumpkin_util::math::{ use serde::Serialize; use std::sync::{ Arc, - atomic::{AtomicBool, AtomicI32}, + atomic::{AtomicBool, AtomicI32, Ordering}, }; use tokio::sync::RwLock; @@ -152,7 +152,6 @@ impl Entity { chunk_pos: AtomicCell::new(Vector2::new(floor_x, floor_z)), sneaking: AtomicBool::new(false), world: Arc::new(RwLock::new(world)), - // TODO: Load this from previous instance sprinting: AtomicBool::new(false), fall_flying: AtomicBool::new(false), yaw: AtomicCell::new(0.0), @@ -498,6 +497,7 @@ impl NBTStorage for Entity { "Rotation", NbtTag::List(vec![self.yaw.load().into(), self.pitch.load().into()].into_boxed_slice()), ); + nbt.put_bool("OnGround", self.on_ground.load(Ordering::Relaxed)); // todo more... } @@ -507,7 +507,8 @@ impl NBTStorage for Entity { let x = position[0].extract_double().unwrap_or(0.0); let y = position[1].extract_double().unwrap_or(0.0); let z = position[2].extract_double().unwrap_or(0.0); - self.pos.store(Vector3::new(x, y, z)); + dbg!(y); + self.set_pos(Vector3::new(x, y, z)); let velocity = nbt.get_list("Motion").unwrap(); let x = velocity[0].extract_double().unwrap_or(0.0); let y = velocity[1].extract_double().unwrap_or(0.0); @@ -516,9 +517,9 @@ impl NBTStorage for Entity { let rotation = nbt.get_list("Rotation").unwrap(); let yaw = rotation[0].extract_float().unwrap_or(0.0); let pitch = rotation[1].extract_float().unwrap_or(0.0); - self.yaw.store(yaw); - self.pitch.store(pitch); - + self.set_rotation(yaw, pitch); + self.on_ground + .store(nbt.get_bool("OnGround").unwrap_or(false), Ordering::Relaxed); // todo more... } } diff --git a/pumpkin/src/world/mod.rs b/pumpkin/src/world/mod.rs index 50c6735d9..4f309f845 100644 --- a/pumpkin/src/world/mod.rs +++ b/pumpkin/src/world/mod.rs @@ -405,9 +405,13 @@ impl World { (position, yaw, pitch) } else { let info = &self.level.level_info; - let position = Vector3::new(f64::from(info.spawn_x), 120.0, f64::from(info.spawn_z)); + let position = Vector3::new( + f64::from(info.spawn_x), + f64::from(info.spawn_y) + 1.0, + f64::from(info.spawn_z), + ); let yaw = info.spawn_angle; - let pitch = 10.0; + let pitch = 0.0; (position, yaw, pitch) }; @@ -686,9 +690,13 @@ impl World { // Teleport let info = &self.level.level_info; - let mut position = Vector3::new(f64::from(info.spawn_x), 120.0, f64::from(info.spawn_z)); + let mut position = Vector3::new( + f64::from(info.spawn_x), + f64::from(info.spawn_y), + f64::from(info.spawn_z), + ); let yaw = info.spawn_angle; - let pitch = 10.0; + let pitch = 0.0; let top = self .get_top_block(Vector2::new(position.x as i32, position.z as i32)) From c961007ced6380e2e2aeb74603d6a17139494aef Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Mon, 24 Mar 2025 20:50:48 +0100 Subject: [PATCH 12/12] fix clippy --- pumpkin/src/data/player_server_data.rs | 9 ++++----- pumpkin/src/server/mod.rs | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pumpkin/src/data/player_server_data.rs b/pumpkin/src/data/player_server_data.rs index 765a0b183..e4a804685 100644 --- a/pumpkin/src/data/player_server_data.rs +++ b/pumpkin/src/data/player_server_data.rs @@ -89,9 +89,8 @@ impl ServerPlayerData { // Save to disk periodically to prevent data loss on server crash if let Err(e) = self.storage.save_player_data(&player.gameprofile.id, nbt) { log::error!( - "Failed to save player data for {}: {}", + "Failed to save player data for {}: {e}", player.gameprofile.id, - e ); } } @@ -119,7 +118,7 @@ impl ServerPlayerData { } } - log::debug!("Saved data for {} online players", total_players); + log::debug!("Saved data for {total_players} online players"); Ok(()) } @@ -152,10 +151,10 @@ impl ServerPlayerData { Err(e) => { if self.storage.is_save_enabled() { // Only log as error if player data saving is enabled - log::error!("Error loading player data for {}: {}", uuid, e); + log::error!("Error loading player data for {uuid}: {e}"); } else { // Otherwise just log as info since it's expected - log::debug!("Not loading player data for {} (saving disabled)", uuid); + log::debug!("Not loading player data for {uuid} (saving disabled)"); } // Continue with default data even if there's an error Ok(()) diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index aa09de79b..43493fcee 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -203,7 +203,7 @@ impl Server { .handle_player_join(&mut player) .await { - log::error!("Unexpected error loading player data: {}", e); + log::error!("Unexpected error loading player data: {e}"); } // Wrap in Arc after data is loaded @@ -493,7 +493,7 @@ impl Server { } if let Err(e) = self.player_data_storage.tick(self).await { - log::error!("Error ticking player data: {}", e); + log::error!("Error ticking player data: {e}"); } } }