From 3b3ae983e48d95b1a1537fe124e2af1ff0d430e6 Mon Sep 17 00:00:00 2001 From: kedor <99351777+aure31@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:39:11 +0200 Subject: [PATCH] feat: add saving/loading advancements (#2257) * implementing the saving and loading advancements for the different players * fix format --- Cargo.lock | 1 + pumpkin/Cargo.toml | 2 + pumpkin/src/data/advancement_data.rs | 86 +++++ pumpkin/src/data/mod.rs | 1 + pumpkin/src/entity/player.rs | 10 + pumpkin/src/entity/player/advancement.rs | 468 +++++++++++++++++++++++ pumpkin/src/server/mod.rs | 11 + 7 files changed, 579 insertions(+) create mode 100644 pumpkin/src/data/advancement_data.rs create mode 100644 pumpkin/src/entity/player/advancement.rs diff --git a/Cargo.lock b/Cargo.lock index eb1efc232..25ecb2e26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2677,6 +2677,7 @@ dependencies = [ "hmac 0.13.0", "hyper", "image", + "indexmap", "libloading", "notify", "num-bigint", diff --git a/pumpkin/Cargo.toml b/pumpkin/Cargo.toml index b994cdf44..f3e9673d7 100644 --- a/pumpkin/Cargo.toml +++ b/pumpkin/Cargo.toml @@ -111,6 +111,8 @@ tracing-serde-structured = { workspace = true } bitflags = { workspace = true } image = { version = "0.25.10", default-features = false, features = ["png"] } +indexmap.workspace = true + [dev-dependencies] tempfile.workspace = true diff --git a/pumpkin/src/data/advancement_data.rs b/pumpkin/src/data/advancement_data.rs new file mode 100644 index 000000000..1fcf6acc0 --- /dev/null +++ b/pumpkin/src/data/advancement_data.rs @@ -0,0 +1,86 @@ +use crate::entity::player::Player; +use crate::entity::player::advancement::{AdvancementDataError, PlayerAdvancement}; +use pumpkin_data::Advancement; +use pumpkin_util::identifier::Identifier; +use std::fs::create_dir_all; +use std::path::PathBuf; +use std::sync::Arc; +use tracing::error; +use uuid::Uuid; + +/// Manages player advancements, including data creation and saving. +pub struct AdvancementManager { + pub advancement_path: PathBuf, + pub save_enabled: bool, +} + +impl AdvancementManager { + /// Creates a new instance of `AdvancementManager` using the player data path. + pub fn new(player_data_path: impl Into, save_enabled: bool) -> Self { + let path = player_data_path.into().join("advancements"); + if !path.exists() + && let Err(e) = create_dir_all(&path) + { + error!( + "Failed to create player data directory at {}: {e}", + path.display() + ); + } + Self { + advancement_path: path, + save_enabled, + } + } + + /// Retrieves the list of all available advancements in the game. + #[must_use] + #[inline] + pub fn get_advancements(&self) -> Vec { + Advancement::get_list().to_vec() + } + + /// Creates and returns a new instance of `PlayerAdvancement` with the configured path. + #[inline] + #[must_use] + pub fn new_player_advancement(self: Arc, owner: Uuid) -> PlayerAdvancement { + PlayerAdvancement::new(self, owner) + } + + /// Saves the advancements of all provided players. + pub async fn save_all_players(players: Vec>) -> Result<(), AdvancementDataError> { + for player in players { + player.advancements.lock().await.save()?; + } + Ok(()) + } + + /// Saves the advancements of a specific player. + pub async fn save_player(player: &Player) -> Result<(), AdvancementDataError> { + player.advancements.lock().await.save()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn advancement_manager_new() { + let path = PathBuf::from("test_data"); + let manager = AdvancementManager::new(path, true); + assert_eq!( + manager.advancement_path, + PathBuf::from("test_data/advancements") + ); + } + + #[test] + fn get_advancement_path() { + let path = PathBuf::from("world/playerdata"); + let manager = AdvancementManager::new(path, true); + let advancement_path = manager.advancement_path; + assert!(advancement_path.ends_with("advancements")); + } +} diff --git a/pumpkin/src/data/mod.rs b/pumpkin/src/data/mod.rs index 71767f354..8841104bd 100644 --- a/pumpkin/src/data/mod.rs +++ b/pumpkin/src/data/mod.rs @@ -8,6 +8,7 @@ const DATA_FOLDER: &str = "data/"; pub mod op; +pub mod advancement_data; pub mod banlist_serializer; pub mod banned_ip; pub mod banned_player; diff --git a/pumpkin/src/entity/player.rs b/pumpkin/src/entity/player.rs index 7b0f5d44d..4bab463ad 100644 --- a/pumpkin/src/entity/player.rs +++ b/pumpkin/src/entity/player.rs @@ -1,3 +1,5 @@ +pub mod advancement; + use core::f32; use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; use std::f64::consts::TAU; @@ -29,6 +31,7 @@ use tokio::task::JoinHandle; use tracing::{debug, warn}; use uuid::Uuid; +use advancement::PlayerAdvancement; use pumpkin_data::attributes::Attributes; use pumpkin_data::block_properties::{BlockProperties, HorizontalFacing}; use pumpkin_data::damage::DamageType; @@ -497,6 +500,7 @@ pub struct Player { pub tab_list_order: AtomicI32, pub tab_list_latency: AtomicI32, pub tab_list_listed: AtomicBool, + pub advancements: Arc>, pub enchantment_seed: AtomicI32, pub fishing_bobber: AtomicI32, pub bedrock_skin: arc_swap::ArcSwap, @@ -618,6 +622,12 @@ impl Player { Self { living_entity, config: ArcSwap::new(Arc::new(config)), + advancements: Arc::new(Mutex::new( + server + .advancement_manager + .clone() + .new_player_advancement(gameprofile.id), + )), gameprofile, client, awaiting_teleport: Mutex::new(None), diff --git a/pumpkin/src/entity/player/advancement.rs b/pumpkin/src/entity/player/advancement.rs new file mode 100644 index 000000000..40bb9fc14 --- /dev/null +++ b/pumpkin/src/entity/player/advancement.rs @@ -0,0 +1,468 @@ +use crate::data::advancement_data::AdvancementManager; +use crate::entity::EntityBase; +use crate::entity::player::Player; +use indexmap::IndexMap; +use pumpkin_data::Advancement; +use pumpkin_data::advancement_data::{AdvancementNode, AdvancementReward}; +use pumpkin_util::text::TextComponent; +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::{from_reader, to_writer_pretty}; +use std::collections::{HashMap, HashSet}; +use std::fs::create_dir_all; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use tracing::{error, warn}; +use uuid::Uuid; + +/// Represents the progress of a given advancement for a player. +/// +/// Tracks whether the advancement has been fully completed. In the future, +/// this will also track specific criteria progress. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +pub struct AdvancementProgress { + /// Indicates if the advancement is fully completed. + pub complete: bool, +} + +impl AdvancementProgress { + /// Returns `true` if the advancement is completely done. + #[must_use] + pub const fn is_done(&self) -> bool { + self.complete + } + + /// Returns `true` if the advancement has any progress. Currently just returns if it is fully complete. + #[must_use] + pub const fn has_progress(&self) -> bool { + self.complete + } +} + +/// Manages a player's collection of advancements. +/// +/// This handles saving, loading, and tracking the state of granted / revoked advancements. +pub struct PlayerAdvancement { + progress: IndexMap<&'static Advancement, AdvancementProgress>, + is_first_packet: bool, + roots_to_update: HashSet<&'static AdvancementNode>, + visible: HashSet<&'static Advancement>, + progress_changed: HashSet<&'static Advancement>, + manager: Arc, + path: PathBuf, + last_selected_tab: Option<&'static Advancement>, + /// A weak reference to the player who owns these advancements. + pub player: Weak, +} + +/// Errors that can occur when saving or loading advancement data. +#[derive(Debug, thiserror::Error)] +pub enum AdvancementDataError { + #[error("IO error: {0}")] + Io(std::io::Error), + #[error("JSON error: {0}")] + Json(serde_json::Error), +} + +impl PlayerAdvancement { + /// Creates a new instance of `PlayerAdvancement`. + #[must_use] + pub fn new(manager: Arc, uuid: Uuid) -> Self { + Self { + progress: IndexMap::new(), + path: manager.advancement_path.join(format!("{}.json", &uuid)), + manager, + player: Weak::new(), + is_first_packet: true, + roots_to_update: HashSet::default(), + visible: HashSet::default(), + progress_changed: HashSet::default(), + last_selected_tab: None, + } + } + + /// Associates the `PlayerAdvancement` data with the given player. + pub fn set_player(&mut self, player: &Arc) { + self.player = Arc::downgrade(player); + } + + /// Returns whether advancement saving is enabled for this player. + #[must_use] + pub fn is_save_enabled(&self) -> bool { + self.manager.save_enabled + } + + ///reload the advancements from the file + pub fn reload(&mut self) -> Result<(), AdvancementDataError> { + //self.stopListening(); TODO + self.progress.clear(); + self.visible.clear(); + self.roots_to_update.clear(); + self.progress_changed.clear(); + self.is_first_packet = true; + self.last_selected_tab = None; + self.load() + } + + /// Saves the player's advancement progress to disk as JSON. + pub fn save(&self) -> Result<(), AdvancementDataError> { + if !self.is_save_enabled() { + return Ok(()); + } + + if let Some(parent) = &self.path.parent() + && let Err(e) = create_dir_all(parent) + { + let file_name = self + .path + .file_prefix() + .and_then(|prefix| prefix.to_str()) + .unwrap_or("unknown"); + error!( + "Failed to create player advancement directory for {}: {e}", + file_name + ); + return Err(AdvancementDataError::Io(e)); + } + let file = std::fs::File::create(&self.path).map_err(AdvancementDataError::Io)?; + + to_writer_pretty(file, &self).map_err(AdvancementDataError::Json)?; + Ok(()) + } + + /// Loads the player's advancement progress from disk. + pub fn load(&mut self) -> Result<(), AdvancementDataError> { + if !self.path.exists() { + return Ok(()); + } + + let file = std::fs::File::open(&self.path).map_err(AdvancementDataError::Io)?; + + let loaded_data: HashMap = + from_reader(file).map_err(AdvancementDataError::Json)?; + + self.progress.clear(); + for (advancement_id, progress) in loaded_data { + if let Some(advancement_ref) = Advancement::from_minecraft_name(&advancement_id) { + self.progress.insert(advancement_ref, progress); + } else { + warn!("The Advancement name {} is invalid", advancement_id); + } + } + Ok(()) + } + + /// Gets the current progress for a given advancement, creating a default uncompleted progress if it doesn't exist. + pub fn get_or_start_progress( + &mut self, + advancement: &'static Advancement, + ) -> &AdvancementProgress { + self.get_mut_or_start_progress(advancement) + } + + /// Gets a mutable reference to the current progress for a given advancement. Creates the state entry if missing. + pub fn get_mut_or_start_progress( + &mut self, + advancement: &'static Advancement, + ) -> &mut AdvancementProgress { + self.progress.entry(advancement).or_default() + } + + /// Grants the rewards (like experience) associated with completing an advancement. + pub async fn grant_reward(player: Arc, reward: &AdvancementReward) { + player.add_experience_points(reward.experience).await; + } + + /// Fully awards an advancement to the player, updating its status to complete and granting rewards if applicable. + pub async fn award(&mut self, advancement: &'static Advancement) { + //TODO call and creates Events for plugins + let player = self.player.upgrade().unwrap().clone(); + let progress = self.get_mut_or_start_progress(advancement); + let is_done = progress.is_done(); + if !progress.is_done() { + progress.complete = true; + Self::grant_reward(player.clone(), advancement.reward).await; + if let Some(display) = advancement.display + && display.announce_to_chat + { + let component = TextComponent::translate( + format!("chat.type.advancement.{}", display.frame_type.get_name()), + [player.get_display_name().await, advancement.name()], + ); + player + .world() + .broadcast_system_message(&component, false) + .await; //send translate component for the event + } + } + if !is_done && progress.is_done() { + //TODO self.mark_for_visibility_update(advancement); + } + } + + /// Revokes a previously awarded advancement, clearing its progress state. + pub fn revoke(&mut self, advancement: &'static Advancement) { + let progress = self.get_mut_or_start_progress(advancement); + let was_done = progress.is_done(); + if progress.is_done() { + progress.complete = false; + } + + if was_done && !progress.is_done() { + //TODO self.mark_for_visibility_update(advancement); + } + } +} + +impl Serialize for PlayerAdvancement { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.progress.len()))?; + + for (advancement, progress) in &self.progress { + map.serialize_entry(&advancement.id, progress)?; + } + map.end() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::advancement_data::AdvancementManager; + use pumpkin_data::Advancement; + use tempfile::tempdir; + + #[test] + fn advancement_progress() { + let progress = AdvancementProgress { complete: false }; + assert!(!progress.is_done()); + assert!(!progress.has_progress()); + + let complete_progress = AdvancementProgress { complete: true }; + assert!(complete_progress.is_done()); + assert!(complete_progress.has_progress()); + } + + #[test] + fn new_player_advancement() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + let id = Uuid::new_v4(); + let pa = PlayerAdvancement::new(manager, id); + assert!(pa.is_save_enabled()); + assert!(pa.is_first_packet); + assert!(pa.roots_to_update.is_empty()); + assert!(pa.progress.is_empty()); + } + + #[test] + fn get_or_start_progress() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager, id); + let adv = Advancement::STORY_ROOT; + let progress = pa.get_or_start_progress(adv); + assert!( + !progress.is_done(), + "New progress should not be marked done by default" + ); + } + + #[test] + fn revoke_advancement() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager, id); + let adv = Advancement::STORY_ROOT; + { + let progress_mut = pa.get_mut_or_start_progress(adv); + progress_mut.complete = true; + }; + assert!(pa.get_or_start_progress(adv).is_done()); + pa.revoke(adv); + assert!(!pa.get_or_start_progress(adv).is_done()); + } + + #[test] + fn save_advancement_progress() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager, id); + + // Add some advancement progress + let adv = Advancement::STORY_ROOT; + { + let progress_mut = pa.get_mut_or_start_progress(adv); + progress_mut.complete = true; + }; + + // Save should succeed + assert!(pa.save().is_ok(), "Save should succeed"); + + // File should exist + assert!(pa.path.exists(), "Saved file should exist"); + + // Content should be valid JSON + let content = std::fs::read_to_string(&pa.path).unwrap(); + assert!(!content.is_empty(), "Saved file should not be empty"); + let _: HashMap = + serde_json::from_str(&content).expect("Saved content should be valid JSON"); + } + + #[test] + fn save_disabled() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), false)); + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager, id); + + // Add some advancement progress + let adv = Advancement::STORY_ROOT; + { + let progress_mut = pa.get_mut_or_start_progress(adv); + progress_mut.complete = true; + }; + + // Save should return Ok but not actually save + assert!( + pa.save().is_ok(), + "Save with disabled saving should return Ok" + ); + assert!( + !pa.path.exists(), + "File should not be created when saving is disabled" + ); + } + + #[test] + fn load_nonexistent_file() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager, id); + + // Load from nonexistent file should return Ok (not error) + assert!( + pa.load().is_ok(), + "Loading from nonexistent file should return Ok" + ); + assert!(pa.progress.is_empty(), "Advancements should remain empty"); + } + + #[test] + fn load_advancement_progress() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager, id); + // Create a JSON file with advancement data + let adv = Advancement::STORY_ROOT; + let data = serde_json::json!({ adv.id.to_string(): { "complete": true } }); + std::fs::write(&pa.path, data.to_string()).unwrap(); + + // Load the file + assert!(pa.load().is_ok(), "Load should succeed"); + + // Verify the advancement was loaded + let progress = pa.get_or_start_progress(adv); + assert!( + progress.is_done(), + "Loaded advancement should be marked complete" + ); + } + + #[test] + fn save_load_roundtrip() { + let temp_dir = tempdir().unwrap(); + + // Create and save advancements + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager.clone(), id); + + let adv = Advancement::STORY_ROOT; + { + let progress_mut = pa.get_mut_or_start_progress(adv); + progress_mut.complete = true; + }; + + assert!(pa.save().is_ok(), "Save should succeed"); + + // Load the saved advancements into a new instance + let mut pa_loaded = PlayerAdvancement::new(manager, id); + assert!(pa_loaded.load().is_ok(), "Load should succeed"); + + // Verify the loaded data matches the saved data + let loaded_progress = pa_loaded.get_or_start_progress(adv); + assert!( + loaded_progress.is_done(), + "Loaded progress should match saved progress" + ); + assert_eq!( + pa_loaded.progress.len(), + pa.progress.len(), + "Loaded advancements count should match" + ); + } + + #[test] + fn load_invalid_advancement_id() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + + // Create a JSON file with invalid advancement ID + let data = serde_json::json!({ + "invalid_advancement_id_12345": { "complete": true } + }); + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager, id); + std::fs::write(&pa.path, data.to_string()).unwrap(); + + // Load should still succeed but skip the invalid entry + + assert!( + pa.load().is_ok(), + "Load should succeed even with invalid IDs" + ); + assert!( + pa.progress.is_empty(), + "Invalid advancements should be skipped" + ); + } + + #[test] + fn save_multiple_advancements() { + let temp_dir = tempdir().unwrap(); + let manager = Arc::new(AdvancementManager::new(temp_dir.path(), true)); + let id = Uuid::new_v4(); + let mut pa = PlayerAdvancement::new(manager, id); + + // Add multiple advancements + let adv1 = Advancement::STORY_ROOT; + let adv2 = Advancement::NETHER_ROOT; + + { + let progress_mut1 = pa.get_mut_or_start_progress(adv1); + progress_mut1.complete = true; + }; + { + let progress_mut2 = pa.get_mut_or_start_progress(adv2); + progress_mut2.complete = false; + }; + + assert!(pa.save().is_ok(), "Save should succeed"); + + // Verify both were saved + let content = std::fs::read_to_string(&pa.path).unwrap(); + let saved_data: HashMap = + serde_json::from_str(&content).unwrap(); + assert_eq!(saved_data.len(), 2, "Should have saved both advancements"); + } +} diff --git a/pumpkin/src/server/mod.rs b/pumpkin/src/server/mod.rs index 17f16bfa2..b7c4cb173 100644 --- a/pumpkin/src/server/mod.rs +++ b/pumpkin/src/server/mod.rs @@ -65,6 +65,7 @@ pub use recipe::RecipeManager; use crate::command::args::entities::{ EntityFilter, EntityFilterSort, EntitySelectorType, TargetSelector, ValueCondition, }; +use crate::data::advancement_data::AdvancementManager; use crate::server::scheduler::TaskScheduler; /// Represents a Minecraft server instance. @@ -116,6 +117,8 @@ pub struct Server { pub defaultgamemode: Mutex, /// Manages player data storage pub player_data_storage: ServerPlayerData, + // Manages player advancement + pub advancement_manager: Arc, // Whether the server whitelist is on or off pub white_list: AtomicBool, /// Manages the server's tick rate, freezing, and sprinting @@ -198,6 +201,7 @@ impl Server { Duration::from_secs(advanced_config.player_data.save_player_cron_interval), advanced_config.player_data.save_player_data, ); + let advancement_manager = Arc::new(AdvancementManager::new(world_path.clone(), true)); let white_list = AtomicBool::new(basic_config.white_list); let tick_rate_manager = Arc::new(ServerTickRateManager::new(basic_config.tps)); @@ -246,6 +250,7 @@ impl Server { map_manager: MapManager::new(), defaultgamemode, player_data_storage, + advancement_manager, white_list, tick_rate_manager, tick_times_nanos: Mutex::new([0; 100]), @@ -492,6 +497,12 @@ impl Server { // Wrap in Arc after data is loaded let player = Arc::new(player); + let mut advancements = player.advancements.lock().await; + if let Err(e) = advancements.load() { + warn!("Error loading player {}: {e}", player.gameprofile.id); + } + advancements.player = Arc::downgrade(&player); + drop(advancements); send_cancellable! {{ self;