Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(player-data): Support saving and loading player NBT Data #600

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

HookWoods
Copy link
Contributor

Description

This PR adds the must-have feature to save and load player NBT Data !

This code actually does:

  • Add a util class to pumpkin-nbt to compress and decompress NBT to GZip which is useful in multiple places in Minecraft code.
  • Load or create player data on join
  • Cache player data to avoid having to read the player file again
  • Save player data on leave
  • Save every player's data on server shutdown
  • Save player data in a scheduler to avoid massive data loss in case of a crash
  • Add a config to control if player data saving should be enabled
  • Add some working content to player NBT

This code also contains a rework of the experience packet and health packet with tick_experience and tick_health; this is needed with how NBT data are handled before player spawning in the world. Like that, we send experience, health, ... directly on the first player tick and send them when there is an update like before.

I also added some data to NBT, like gamemode, or food, and impl them in the code to use them now instead of default data. Now the future part needed for this code is the implementation of other NBT values accessible from https://minecraft.wiki/w/Player.dat_format

Testing

I have tested to move on the map, give experience, change gamemode, ... stopping the server and joining again and all is working good. May need more test from others !

Please follow our Coding Guidelines

/// Is Player Data saving enabled?
pub save_player_data: bool,
/// Is Player Data should be cached?
pub cache_player_data: bool,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to cache player data, just read it back from disk when they rejoin

Comment on lines +16 to +29
pub fn read_gzip_compound_tag(compressed_data: &[u8]) -> Result<NbtCompound, Error> {
// 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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should take an impl Read, then you can:

Nbt::read(&mut ReadAdaptor::new(GzDecoder::new(input)))

and skip having to buffer the data.

Comment on lines +42 to +55
pub fn write_gzip_compound_tag(compound: &NbtCompound) -> Result<Vec<u8>, 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should add a write_to(impl Write for Nbt and chain the writers like the readers above to avoid having to write to a buffer.

Comment on lines +69 to +84
pub fn from_gzip_bytes<'a, T>(compressed_data: &[u8]) -> Result<T, Error>
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[..])
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't write to a buffer here, just chain the readers

Comment on lines +97 to +116
pub fn to_gzip_bytes<T>(value: &T) -> Result<Vec<u8>, 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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't write to a buffer here, just chain the writers.

/// Path to the directory where player data is stored
data_path: PathBuf,
/// In-memory cache of recently disconnected players' data
cache: Mutex<HashMap<Uuid, (NbtCompound, Instant)>>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this really need to be a mutex? Can you have PlayerDataStorage be mutable when you need to modify it?

pub struct ServerPlayerData {
storage: Arc<PlayerDataStorage>,
save_interval: Duration,
last_cleanup: Mutex<Instant>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these need to be mutexs? can server player data be mutable when you need to modify them?

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}")),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the player data is not found, shouldn't a new file be created?

@@ -137,6 +139,12 @@ impl Server {
server_listing: Mutex::new(CachedStatus::new()),
server_branding: CachedBranding::new(),
bossbars: Mutex::new(CustomBossbars::new()),
player_data_storage: ServerPlayerData::new(
"./world/playerdata", // TODO: handle world name in config
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should make a helper function for getting the world name and feed that here and where the chunk loading gets the world name

@Snowiiii
Copy link
Collaborator

Snowiiii commented Mar 1, 2025

Why not move the nbt data into the hunger manager

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants