Skip to main content

steel_core/player/player_data_storage/
mod.rs

1//! Player data storage for global and domain-scoped player state.
2
3mod known_players;
4mod permissions;
5
6use std::{
7    io::Cursor,
8    path::{Path, PathBuf},
9    sync::Arc,
10};
11
12use rustc_hash::FxHashMap;
13use simdnbt::{ToNbtTag, borrow::read_compound as read_borrowed_compound, owned::NbtTag};
14use tokio::{
15    fs,
16    io::{self, AsyncWriteExt},
17};
18use uuid::Uuid;
19use wincode::{SchemaRead, SchemaWrite};
20
21#[cfg(test)]
22use self::permissions::set_permission_subject;
23use self::{
24    known_players::{KnownPlayersFile, decode_known_players_file, encode_known_players_file},
25    permissions::{PlayerPermissionsFile, serialize_player_permissions_file},
26};
27use super::player_data::{
28    PLAYER_DATA_VERSION, PersistentAbilities, PersistentEnderPearl, PersistentPlayerData,
29    PersistentRootVehicle, PersistentSlot,
30};
31use crate::chunk_saver::PersistentEntity;
32use crate::config::StorageSelection;
33use crate::permission::PermissionSubjectIndex;
34#[cfg(test)]
35use crate::permission::PermissionSubjectState;
36use crate::player::KnownPlayers;
37use crate::player::Player;
38use steel_registry::item_stack::ItemStack;
39use steel_utils::Identifier;
40use steel_utils::locks::{AsyncMutex, SyncMutex};
41
42const PLAYER_MAGIC: [u8; 4] = *b"STLP";
43const GLOBAL_MAGIC: [u8; 4] = *b"STLG";
44const PLAYER_STORAGE_VERSION: u16 = 7;
45const GLOBAL_STORAGE_VERSION: u16 = 1;
46const GLOBAL_PLAYER_DATA_VERSION: i32 = 1;
47
48/// Server-wide player data.
49#[derive(Debug, Clone)]
50pub struct GlobalPlayerData {
51    /// Last active domain for reconnects.
52    pub last_active_domain: String,
53}
54
55/// Manages player data persistence.
56pub struct PlayerDataStorage {
57    backend: PlayerDataStorageBackend,
58}
59
60enum PlayerDataStorageBackend {
61    File(FilePlayerDataStorage),
62}
63
64struct FilePlayerDataStorage {
65    save_root: PathBuf,
66    file_locks: SyncMutex<FxHashMap<PathBuf, Arc<AsyncMutex<()>>>>,
67}
68
69#[derive(SchemaWrite, SchemaRead)]
70struct PlayerDataFile {
71    data_version: i32,
72    pos: [f64; 3],
73    motion: [f64; 3],
74    rotation: [f32; 2],
75    on_ground: bool,
76    fall_flying: bool,
77    remaining_fire_ticks: i32,
78    ticks_frozen: i32,
79    is_in_powder_snow: bool,
80    was_in_powder_snow: bool,
81    has_visual_fire: bool,
82    health: f32,
83    game_mode: i32,
84    prev_game_mode: Option<i32>,
85    abilities: AbilitiesFile,
86    inventory: Vec<SlotFile>,
87    selected_slot: i32,
88    world: String,
89    food_level: i32,
90    food_saturation_level: f32,
91    food_exhaustion_level: f32,
92    food_tick_timer: i32,
93    experience_level: i32,
94    experience_progress: f32,
95    experience_total: i32,
96    score: i32,
97    seen_credits: bool,
98    root_vehicle: Option<RootVehicleFile>,
99    ender_pearls: Vec<EnderPearlFile>,
100}
101
102#[derive(SchemaWrite, SchemaRead)]
103struct RootVehicleFile {
104    attach: [u8; 16],
105    entity: PersistentEntity,
106}
107
108#[derive(SchemaWrite, SchemaRead)]
109struct EnderPearlFile {
110    world: String,
111    entity: PersistentEntity,
112}
113
114#[derive(SchemaWrite, SchemaRead)]
115struct AbilitiesFile {
116    invulnerable: bool,
117    flying: bool,
118    may_fly: bool,
119    instabuild: bool,
120    may_build: bool,
121    flying_speed: f32,
122    walking_speed: f32,
123}
124
125#[derive(SchemaWrite, SchemaRead)]
126struct SlotFile {
127    slot: i8,
128    item_nbt: Vec<u8>,
129}
130
131#[derive(SchemaWrite, SchemaRead)]
132struct GlobalPlayerDataFile {
133    data_version: i32,
134    last_active_domain: String,
135}
136
137impl PlayerDataStorage {
138    /// Creates player data storage from config.
139    pub async fn new(save_root: PathBuf, selection: StorageSelection) -> io::Result<Self> {
140        if selection.kind != Identifier::from_steel("file") {
141            return Err(io::Error::new(
142                io::ErrorKind::InvalidInput,
143                format!("unknown player storage {}", selection.kind),
144            ));
145        }
146        let backend = PlayerDataStorageBackend::File(FilePlayerDataStorage::new(save_root).await?);
147        Ok(Self { backend })
148    }
149
150    /// Saves a player's current domain data and global last-active-domain.
151    pub async fn save(&self, player: &Player) -> io::Result<()> {
152        let domain = player.get_world().domain().to_owned();
153        self.save_domain(&domain, player).await?;
154        self.save_global(
155            player.gameprofile.id,
156            &GlobalPlayerData {
157                last_active_domain: domain,
158            },
159        )
160        .await
161    }
162
163    /// Saves a player's data for a specific domain.
164    pub async fn save_domain(&self, domain: &str, player: &Player) -> io::Result<()> {
165        match &self.backend {
166            PlayerDataStorageBackend::File(storage) => storage.save_domain(domain, player).await,
167        }
168    }
169
170    /// Saves an already captured player data snapshot for a specific domain.
171    pub async fn save_domain_data(
172        &self,
173        domain: &str,
174        uuid: Uuid,
175        data: &PersistentPlayerData,
176    ) -> io::Result<()> {
177        match &self.backend {
178            PlayerDataStorageBackend::File(storage) => {
179                storage.save_domain_data(domain, uuid, data).await
180            }
181        }
182    }
183
184    /// Loads a player's data for a specific domain.
185    pub async fn load_domain(
186        &self,
187        domain: &str,
188        uuid: Uuid,
189    ) -> io::Result<Option<PersistentPlayerData>> {
190        match &self.backend {
191            PlayerDataStorageBackend::File(storage) => storage.load_domain(domain, uuid).await,
192        }
193    }
194
195    /// Loads server-wide player data.
196    pub async fn load_global(&self, uuid: Uuid) -> io::Result<Option<GlobalPlayerData>> {
197        match &self.backend {
198            PlayerDataStorageBackend::File(storage) => storage.load_global(uuid).await,
199        }
200    }
201
202    /// Loads all persisted player permission snapshots.
203    pub async fn load_permission_subjects(&self) -> io::Result<PermissionSubjectIndex> {
204        match &self.backend {
205            PlayerDataStorageBackend::File(storage) => storage.load_permission_subjects().await,
206        }
207    }
208
209    /// Loads the rebuildable player identity cache, falling back to empty on failure.
210    pub async fn load_known_players(&self) -> io::Result<KnownPlayers> {
211        match &self.backend {
212            PlayerDataStorageBackend::File(storage) => storage.load_known_players().await,
213        }
214    }
215
216    /// Persists the identity cache when the caller's snapshot is still current.
217    pub async fn save_known_players_if_current(
218        &self,
219        players: &KnownPlayers,
220        is_current: impl FnOnce() -> bool + Send,
221    ) -> io::Result<bool> {
222        match &self.backend {
223            PlayerDataStorageBackend::File(storage) => {
224                storage
225                    .save_known_players_if_current(players, is_current)
226                    .await
227            }
228        }
229    }
230
231    /// Saves server-wide player data.
232    pub async fn save_global(&self, uuid: Uuid, data: &GlobalPlayerData) -> io::Result<()> {
233        match &self.backend {
234            PlayerDataStorageBackend::File(storage) => storage.save_global(uuid, data).await,
235        }
236    }
237
238    /// Persists the server's complete UUID-keyed permission snapshot.
239    pub async fn save_permission_subjects(
240        &self,
241        subjects: &PermissionSubjectIndex,
242    ) -> io::Result<()> {
243        match &self.backend {
244            PlayerDataStorageBackend::File(storage) => {
245                storage.save_permission_subjects(subjects).await
246            }
247        }
248    }
249}
250
251impl FilePlayerDataStorage {
252    async fn new(save_root: PathBuf) -> io::Result<Self> {
253        fs::create_dir_all(save_root.join("global").join("players")).await?;
254        Ok(Self {
255            save_root,
256            file_locks: SyncMutex::new(FxHashMap::default()),
257        })
258    }
259
260    async fn save_domain(&self, domain: &str, player: &Player) -> io::Result<()> {
261        let uuid = player.gameprofile.id;
262        let data = PersistentPlayerData::from_player(player);
263        self.save_domain_data(domain, uuid, &data).await
264    }
265
266    async fn save_domain_data(
267        &self,
268        domain: &str,
269        uuid: Uuid,
270        data: &PersistentPlayerData,
271    ) -> io::Result<()> {
272        let file = PlayerDataFile::from_persistent(data)?;
273        let bytes = encode_player_file(&file)?;
274        self.write_atomic(&self.domain_players_dir(domain), uuid, bytes)
275            .await?;
276        log::debug!("Saved player data for {uuid} in domain {domain}");
277        Ok(())
278    }
279
280    async fn load_domain(
281        &self,
282        domain: &str,
283        uuid: Uuid,
284    ) -> io::Result<Option<PersistentPlayerData>> {
285        let path = Self::player_file(&self.domain_players_dir(domain), uuid);
286        let lock = self.file_lock(&path);
287        let _guard = lock.lock().await;
288        if !Self::recover_missing_atomic_path_locked(&path).await? {
289            return Ok(None);
290        }
291        let bytes = fs::read(&path).await?;
292        let file = decode_player_file(&bytes)?;
293        let data = file.into_persistent()?;
294        log::debug!("Loaded player data for {uuid} in domain {domain}");
295        Ok(Some(data))
296    }
297
298    async fn load_global(&self, uuid: Uuid) -> io::Result<Option<GlobalPlayerData>> {
299        let path = Self::player_file(&self.global_players_dir(), uuid);
300        let lock = self.file_lock(&path);
301        let _guard = lock.lock().await;
302        if !Self::recover_missing_atomic_path_locked(&path).await? {
303            return Ok(None);
304        }
305        let bytes = fs::read(&path).await?;
306        let file = decode_global_file(&bytes)?;
307        Ok(Some(GlobalPlayerData {
308            last_active_domain: file.last_active_domain,
309        }))
310    }
311
312    async fn load_permission_subjects(&self) -> io::Result<PermissionSubjectIndex> {
313        self.load_player_permissions_file()
314            .await?
315            .into_subject_index()
316    }
317
318    async fn load_known_players(&self) -> io::Result<KnownPlayers> {
319        let path = self.known_players_file();
320        let lock = self.file_lock(&path);
321        let _guard = lock.lock().await;
322        match Self::read_known_players_file_locked(&path).await {
323            Ok(players) => Ok(players),
324            Err(error) => {
325                log::warn!(
326                    "Failed to load known player cache from {}: {error}. Starting with an empty cache",
327                    path.display()
328                );
329                Ok(KnownPlayers::new())
330            }
331        }
332    }
333
334    async fn read_known_players_file_locked(path: &Path) -> io::Result<KnownPlayers> {
335        if !Self::recover_missing_atomic_path_locked(path).await? {
336            return Ok(KnownPlayers::new());
337        }
338        let bytes = fs::read(path).await?;
339        decode_known_players_file(&bytes)?.into_known_players()
340    }
341
342    async fn save_known_players_if_current(
343        &self,
344        players: &KnownPlayers,
345        is_current: impl FnOnce() -> bool + Send,
346    ) -> io::Result<bool> {
347        let path = self.known_players_file();
348        let lock = self.file_lock(&path);
349        let _guard = lock.lock().await;
350        if !is_current() {
351            return Ok(false);
352        }
353        let bytes = encode_known_players_file(&KnownPlayersFile::from_known_players(players))?;
354        Self::write_atomic_path_locked(&path, bytes).await?;
355        Ok(true)
356    }
357
358    async fn save_global(&self, uuid: Uuid, data: &GlobalPlayerData) -> io::Result<()> {
359        let file = GlobalPlayerDataFile {
360            data_version: GLOBAL_PLAYER_DATA_VERSION,
361            last_active_domain: data.last_active_domain.clone(),
362        };
363        let bytes = encode_global_file(&file)?;
364        self.write_atomic(&self.global_players_dir(), uuid, bytes)
365            .await
366    }
367
368    async fn save_permission_subjects(&self, subjects: &PermissionSubjectIndex) -> io::Result<()> {
369        let path = self.player_permissions_file();
370        let lock = self.file_lock(&path);
371        let _guard = lock.lock().await;
372        let file = PlayerPermissionsFile::from_subject_index(subjects);
373        self.write_player_permissions_file_locked(&path, &file)
374            .await
375    }
376
377    async fn load_player_permissions_file(&self) -> io::Result<PlayerPermissionsFile> {
378        let path = self.player_permissions_file();
379        let lock = self.file_lock(&path);
380        let _guard = lock.lock().await;
381        self.read_player_permissions_file_locked(&path).await
382    }
383
384    async fn read_player_permissions_file_locked(
385        &self,
386        path: &Path,
387    ) -> io::Result<PlayerPermissionsFile> {
388        if !Self::recover_missing_atomic_path_locked(path).await? {
389            return Ok(PlayerPermissionsFile::default());
390        }
391        let contents = fs::read_to_string(path).await?;
392        let file = toml::from_str::<PlayerPermissionsFile>(&contents).map_err(|error| {
393            io::Error::new(
394                io::ErrorKind::InvalidData,
395                format!(
396                    "invalid player permissions TOML in {}: {error}",
397                    path.display()
398                ),
399            )
400        })?;
401        file.validate()?;
402        Ok(file)
403    }
404
405    async fn write_player_permissions_file_locked(
406        &self,
407        path: &Path,
408        file: &PlayerPermissionsFile,
409    ) -> io::Result<()> {
410        let contents = serialize_player_permissions_file(file).map_err(|error| {
411            io::Error::new(
412                io::ErrorKind::InvalidData,
413                format!("failed to serialize player permissions TOML: {error}"),
414            )
415        })?;
416        Self::write_atomic_path_locked(path, contents.into_bytes()).await
417    }
418
419    fn global_dir(&self) -> PathBuf {
420        self.save_root.join("global")
421    }
422
423    fn global_players_dir(&self) -> PathBuf {
424        self.global_dir().join("players")
425    }
426
427    fn player_permissions_file(&self) -> PathBuf {
428        self.global_dir().join("player_permissions.toml")
429    }
430
431    fn known_players_file(&self) -> PathBuf {
432        self.global_dir().join("known_players.dat")
433    }
434
435    fn domain_players_dir(&self, domain: &str) -> PathBuf {
436        self.save_root.join(domain).join("players")
437    }
438
439    fn player_file(players_dir: &Path, uuid: Uuid) -> PathBuf {
440        players_dir.join(format!("{uuid}.dat"))
441    }
442
443    fn file_lock(&self, path: &Path) -> Arc<AsyncMutex<()>> {
444        let mut locks = self.file_locks.lock();
445        locks
446            .entry(path.to_path_buf())
447            .or_insert_with(|| Arc::new(AsyncMutex::new(())))
448            .clone()
449    }
450
451    async fn write_atomic(&self, players_dir: &Path, uuid: Uuid, bytes: Vec<u8>) -> io::Result<()> {
452        let final_path = Self::player_file(players_dir, uuid);
453        let lock = self.file_lock(&final_path);
454        let _guard = lock.lock().await;
455        Self::write_atomic_path_locked(&final_path, bytes).await
456    }
457
458    async fn write_atomic_path_locked(final_path: &Path, bytes: Vec<u8>) -> io::Result<()> {
459        let Some(parent) = final_path.parent() else {
460            return Err(io::Error::new(
461                io::ErrorKind::InvalidInput,
462                "atomic write path has no parent",
463            ));
464        };
465        fs::create_dir_all(parent).await?;
466        let temp_path = Self::atomic_temp_path(final_path);
467        let backup_path = Self::atomic_backup_path(final_path);
468        let backup_temp_path = Self::atomic_temp_path(&backup_path);
469
470        Self::write_synced_file(&temp_path, &bytes).await?;
471        if fs::try_exists(final_path).await? {
472            Self::copy_synced_file(final_path, &backup_temp_path).await?;
473            fs::rename(&backup_temp_path, &backup_path).await?;
474        }
475        fs::rename(&temp_path, final_path).await?;
476        if let Err(error) = Self::sync_parent(parent).await {
477            tracing::error!(
478                %error,
479                path = %final_path.display(),
480                "Atomic data-file replacement committed, but directory sync failed; crash durability is uncertain"
481            );
482        }
483        Ok(())
484    }
485
486    fn atomic_temp_path(path: &Path) -> PathBuf {
487        let extension = path.extension().and_then(|value| value.to_str());
488        path.with_extension(match extension {
489            Some(extension) => format!("{extension}.tmp"),
490            None => "tmp".to_owned(),
491        })
492    }
493
494    fn atomic_backup_path(path: &Path) -> PathBuf {
495        let extension = path.extension().and_then(|value| value.to_str());
496        path.with_extension(match extension {
497            Some(extension) => format!("{extension}_old"),
498            None => "old".to_owned(),
499        })
500    }
501
502    async fn recover_missing_atomic_path_locked(final_path: &Path) -> io::Result<bool> {
503        if fs::try_exists(final_path).await? {
504            return Ok(true);
505        }
506
507        let backup_path = Self::atomic_backup_path(final_path);
508        if fs::try_exists(&backup_path).await? {
509            fs::rename(&backup_path, final_path).await?;
510            let Some(parent) = final_path.parent() else {
511                return Err(io::Error::new(
512                    io::ErrorKind::InvalidInput,
513                    "atomic recovery path has no parent",
514                ));
515            };
516            Self::sync_parent(parent).await?;
517            let temp_path = Self::atomic_temp_path(final_path);
518            if fs::try_exists(&temp_path).await?
519                && let Err(error) = fs::remove_file(&temp_path).await
520            {
521                tracing::warn!(
522                    %error,
523                    path = %temp_path.display(),
524                    "Failed to remove an uncommitted atomic-write temporary file"
525                );
526            }
527            tracing::warn!(
528                path = %final_path.display(),
529                backup = %backup_path.display(),
530                "Recovered a missing data file from its last committed backup"
531            );
532            return Ok(true);
533        }
534
535        let temp_path = Self::atomic_temp_path(final_path);
536        if fs::try_exists(&temp_path).await? {
537            if let Err(error) = fs::remove_file(&temp_path).await {
538                tracing::warn!(
539                    %error,
540                    path = %temp_path.display(),
541                    "Failed to remove an uncommitted atomic-write temporary file"
542                );
543            }
544            tracing::warn!(
545                path = %final_path.display(),
546                temporary = %temp_path.display(),
547                "Discarded an interrupted data-file publication with no committed generation"
548            );
549        }
550
551        Ok(false)
552    }
553
554    async fn write_synced_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
555        let mut file = fs::File::create(path).await?;
556        file.write_all(bytes).await?;
557        file.sync_all().await
558    }
559
560    async fn copy_synced_file(source: &Path, destination: &Path) -> io::Result<()> {
561        let mut source = fs::File::open(source).await?;
562        let mut destination = fs::File::create(destination).await?;
563        io::copy(&mut source, &mut destination).await?;
564        destination.sync_all().await
565    }
566
567    async fn sync_parent(parent: &Path) -> io::Result<()> {
568        #[cfg(unix)]
569        fs::File::open(parent).await?.sync_all().await?;
570        #[cfg(not(unix))]
571        let _ = parent;
572        Ok(())
573    }
574}
575
576impl PlayerDataFile {
577    fn from_persistent(data: &PersistentPlayerData) -> io::Result<Self> {
578        let mut inventory = Vec::with_capacity(data.inventory.len());
579        for slot in &data.inventory {
580            inventory.push(SlotFile {
581                slot: slot.slot,
582                item_nbt: item_to_nbt_bytes(&slot.item)?,
583            });
584        }
585
586        Ok(Self {
587            data_version: data.data_version,
588            pos: data.pos,
589            motion: data.motion,
590            rotation: data.rotation,
591            on_ground: data.on_ground,
592            fall_flying: data.fall_flying,
593            remaining_fire_ticks: data.remaining_fire_ticks,
594            ticks_frozen: data.ticks_frozen,
595            is_in_powder_snow: data.is_in_powder_snow,
596            was_in_powder_snow: data.was_in_powder_snow,
597            has_visual_fire: data.has_visual_fire,
598            health: data.health,
599            game_mode: data.game_mode,
600            prev_game_mode: data.prev_game_mode,
601            abilities: AbilitiesFile {
602                invulnerable: data.abilities.invulnerable,
603                flying: data.abilities.flying,
604                may_fly: data.abilities.may_fly,
605                instabuild: data.abilities.instabuild,
606                may_build: data.abilities.may_build,
607                flying_speed: data.abilities.flying_speed,
608                walking_speed: data.abilities.walking_speed,
609            },
610            inventory,
611            selected_slot: data.selected_slot,
612            world: data.world.clone(),
613            food_level: data.food_level,
614            food_saturation_level: data.food_saturation_level,
615            food_exhaustion_level: data.food_exhaustion_level,
616            food_tick_timer: data.food_tick_timer,
617            experience_level: data.experience_level,
618            experience_progress: data.experience_progress,
619            experience_total: data.experience_total,
620            score: data.score,
621            seen_credits: data.seen_credits,
622            root_vehicle: data
623                .root_vehicle
624                .clone()
625                .map(|root_vehicle| RootVehicleFile {
626                    attach: root_vehicle.attach,
627                    entity: root_vehicle.entity,
628                }),
629            ender_pearls: data
630                .ender_pearls
631                .iter()
632                .map(|pearl| EnderPearlFile {
633                    world: pearl.world.clone(),
634                    entity: pearl.entity.clone(),
635                })
636                .collect(),
637        })
638    }
639
640    fn into_persistent(self) -> io::Result<PersistentPlayerData> {
641        if self.data_version != PLAYER_DATA_VERSION {
642            return Err(io::Error::new(
643                io::ErrorKind::InvalidData,
644                format!(
645                    "unsupported player data payload version {}",
646                    self.data_version
647                ),
648            ));
649        }
650
651        let mut inventory = Vec::with_capacity(self.inventory.len());
652        for slot in self.inventory {
653            inventory.push(PersistentSlot {
654                slot: slot.slot,
655                item: item_from_nbt_bytes(&slot.item_nbt)?,
656            });
657        }
658
659        Ok(PersistentPlayerData {
660            pos: self.pos,
661            motion: self.motion,
662            rotation: self.rotation,
663            on_ground: self.on_ground,
664            fall_flying: self.fall_flying,
665            remaining_fire_ticks: self.remaining_fire_ticks,
666            ticks_frozen: self.ticks_frozen,
667            is_in_powder_snow: self.is_in_powder_snow,
668            was_in_powder_snow: self.was_in_powder_snow,
669            has_visual_fire: self.has_visual_fire,
670            health: self.health,
671            game_mode: self.game_mode,
672            prev_game_mode: self.prev_game_mode,
673            abilities: PersistentAbilities {
674                invulnerable: self.abilities.invulnerable,
675                flying: self.abilities.flying,
676                may_fly: self.abilities.may_fly,
677                instabuild: self.abilities.instabuild,
678                may_build: self.abilities.may_build,
679                flying_speed: self.abilities.flying_speed,
680                walking_speed: self.abilities.walking_speed,
681            },
682            inventory,
683            selected_slot: self.selected_slot,
684            world: self.world,
685            food_level: self.food_level,
686            food_saturation_level: self.food_saturation_level,
687            food_exhaustion_level: self.food_exhaustion_level,
688            food_tick_timer: self.food_tick_timer,
689            data_version: self.data_version,
690            experience_level: self.experience_level,
691            experience_progress: self.experience_progress,
692            experience_total: self.experience_total,
693            score: self.score,
694            seen_credits: self.seen_credits,
695            root_vehicle: self.root_vehicle.map(|root_vehicle| PersistentRootVehicle {
696                attach: root_vehicle.attach,
697                entity: root_vehicle.entity,
698            }),
699            ender_pearls: self
700                .ender_pearls
701                .into_iter()
702                .map(|pearl| PersistentEnderPearl {
703                    world: pearl.world,
704                    entity: pearl.entity,
705                })
706                .collect(),
707        })
708    }
709}
710
711fn item_to_nbt_bytes(item: &ItemStack) -> io::Result<Vec<u8>> {
712    let NbtTag::Compound(compound) = item.clone().to_nbt_tag() else {
713        return Err(io::Error::new(
714            io::ErrorKind::InvalidData,
715            "item stack did not serialize to a compound",
716        ));
717    };
718    let mut bytes = Vec::new();
719    compound.write(&mut bytes);
720    Ok(bytes)
721}
722
723fn item_from_nbt_bytes(bytes: &[u8]) -> io::Result<ItemStack> {
724    let nbt = read_borrowed_compound(&mut Cursor::new(bytes)).map_err(|e| {
725        io::Error::new(
726            io::ErrorKind::InvalidData,
727            format!("failed to parse item NBT: {e}"),
728        )
729    })?;
730    let compound = simdnbt::borrow::NbtCompound::from(&nbt);
731    ItemStack::from_borrowed_compound(&compound)
732        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid item stack data"))
733}
734
735fn encode_player_file(file: &PlayerDataFile) -> io::Result<Vec<u8>> {
736    encode_file(
737        PLAYER_MAGIC,
738        PLAYER_STORAGE_VERSION,
739        wincode::serialize(file),
740    )
741}
742
743fn decode_player_file(bytes: &[u8]) -> io::Result<PlayerDataFile> {
744    let payload = decode_file(PLAYER_MAGIC, PLAYER_STORAGE_VERSION, bytes)?;
745    wincode::deserialize(&payload)
746        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
747}
748
749fn encode_global_file(file: &GlobalPlayerDataFile) -> io::Result<Vec<u8>> {
750    encode_file(
751        GLOBAL_MAGIC,
752        GLOBAL_STORAGE_VERSION,
753        wincode::serialize(file),
754    )
755}
756
757fn decode_global_file(bytes: &[u8]) -> io::Result<GlobalPlayerDataFile> {
758    let payload = decode_file(GLOBAL_MAGIC, GLOBAL_STORAGE_VERSION, bytes)?;
759    wincode::deserialize(&payload)
760        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
761}
762
763fn encode_file(
764    magic: [u8; 4],
765    version: u16,
766    serialized: wincode::WriteResult<Vec<u8>>,
767) -> io::Result<Vec<u8>> {
768    let payload =
769        serialized.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
770    let compressed = zstd::encode_all(&payload[..], 3)?;
771    let mut bytes = Vec::with_capacity(6 + compressed.len());
772    bytes.extend_from_slice(&magic);
773    bytes.extend_from_slice(&version.to_le_bytes());
774    bytes.extend_from_slice(&compressed);
775    Ok(bytes)
776}
777
778fn decode_file(
779    expected_magic: [u8; 4],
780    expected_version: u16,
781    bytes: &[u8],
782) -> io::Result<Vec<u8>> {
783    if bytes.len() < 6 {
784        return Err(io::Error::new(
785            io::ErrorKind::InvalidData,
786            "player data file is too short",
787        ));
788    }
789    if bytes[0..4] != expected_magic {
790        return Err(io::Error::new(
791            io::ErrorKind::InvalidData,
792            "invalid player data magic",
793        ));
794    }
795    let version = u16::from_le_bytes([bytes[4], bytes[5]]);
796    if version != expected_version {
797        return Err(io::Error::new(
798            io::ErrorKind::InvalidData,
799            format!("unsupported player data storage version {version}"),
800        ));
801    }
802    zstd::decode_all(&bytes[6..])
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use crate::entity::DEFAULT_MAX_AIR_SUPPLY;
809    use crate::permission::PermissionSet;
810    use crate::player::KnownPlayer;
811    use std::{
812        env,
813        time::{SystemTime, UNIX_EPOCH},
814    };
815
816    fn temp_storage_root(name: &str) -> PathBuf {
817        let suffix = SystemTime::now()
818            .duration_since(UNIX_EPOCH)
819            .expect("system clock should be after unix epoch")
820            .as_nanos();
821        env::temp_dir().join(format!("steelmc-player-storage-{name}-{suffix}"))
822    }
823
824    fn sample_player_file(data_version: i32) -> PlayerDataFile {
825        PlayerDataFile {
826            data_version,
827            pos: [1.0, 2.0, 3.0],
828            motion: [0.0, 0.0, 0.0],
829            rotation: [90.0, 10.0],
830            on_ground: true,
831            fall_flying: false,
832            remaining_fire_ticks: 0,
833            ticks_frozen: 0,
834            is_in_powder_snow: false,
835            was_in_powder_snow: false,
836            has_visual_fire: false,
837            health: 20.0,
838            game_mode: 2,
839            prev_game_mode: Some(0),
840            abilities: AbilitiesFile {
841                invulnerable: false,
842                flying: false,
843                may_fly: false,
844                instabuild: false,
845                may_build: true,
846                flying_speed: 0.05,
847                walking_speed: 0.1,
848            },
849            inventory: Vec::new(),
850            selected_slot: 4,
851            world: "lobby:void".to_owned(),
852            food_level: 20,
853            food_saturation_level: 5.0,
854            food_exhaustion_level: 0.0,
855            food_tick_timer: 0,
856            experience_level: 7,
857            experience_progress: 0.5,
858            experience_total: 32,
859            score: 9,
860            seen_credits: true,
861            root_vehicle: None,
862            ender_pearls: Vec::new(),
863        }
864    }
865
866    fn sample_persistent_entity() -> PersistentEntity {
867        PersistentEntity {
868            entity_type: Identifier::vanilla_static("minecart"),
869            uuid: [7; 16],
870            pos: [4.0, 65.0, 6.0],
871            motion: [0.0, 0.0, 0.0],
872            rotation: [45.0, 0.0],
873            fall_distance: 0.0,
874            remaining_fire_ticks: 0,
875            ticks_frozen: 0,
876            is_in_powder_snow: false,
877            was_in_powder_snow: false,
878            has_visual_fire: false,
879            on_ground: true,
880            no_gravity: false,
881            invulnerable: false,
882            air_supply: DEFAULT_MAX_AIR_SUPPLY,
883            portal_cooldown: 0,
884            custom_name_nbt: Vec::new(),
885            custom_name_visible: false,
886            silent: false,
887            glowing: false,
888            tags: Vec::new(),
889            custom_data_nbt: Vec::new(),
890            nbt_data: Vec::new(),
891            passengers: Vec::new(),
892        }
893    }
894
895    #[tokio::test]
896    async fn atomic_path_replacement_retains_the_last_committed_generation() {
897        let root = temp_storage_root("atomic-replacement");
898        let path = root.join("state.dat");
899
900        FilePlayerDataStorage::write_atomic_path_locked(&path, b"first".to_vec())
901            .await
902            .expect("first generation should publish");
903        FilePlayerDataStorage::write_atomic_path_locked(&path, b"second".to_vec())
904            .await
905            .expect("second generation should publish");
906
907        assert_eq!(
908            fs::read(&path).await.expect("live file should be readable"),
909            b"second"
910        );
911        assert_eq!(
912            fs::read(FilePlayerDataStorage::atomic_backup_path(&path))
913                .await
914                .expect("backup should be readable"),
915            b"first"
916        );
917
918        fs::remove_dir_all(root)
919            .await
920            .expect("temporary storage should be removable");
921    }
922
923    #[tokio::test]
924    async fn interrupted_permission_publication_recovers_before_the_next_update() {
925        let root = temp_storage_root("permission-recovery");
926        let storage = FilePlayerDataStorage::new(root.clone())
927            .await
928            .expect("test storage should initialize");
929        let mut subjects = PermissionSubjectIndex::new();
930        for (uuid, group) in [
931            (Uuid::from_u128(10), "builder"),
932            (Uuid::from_u128(20), "moderator"),
933        ] {
934            subjects.set(
935                uuid,
936                PermissionSubjectState::new(vec![group.to_owned()], PermissionSet::new()),
937            );
938        }
939        storage
940            .save_permission_subjects(&subjects)
941            .await
942            .expect("permission subjects should persist");
943
944        let path = storage.player_permissions_file();
945        let backup = FilePlayerDataStorage::atomic_backup_path(&path);
946        let temporary = FilePlayerDataStorage::atomic_temp_path(&path);
947        fs::rename(&path, &backup)
948            .await
949            .expect("legacy publication should reach its interrupted state");
950        fs::write(&temporary, b"uncommitted replacement")
951            .await
952            .expect("uncommitted replacement should be staged");
953
954        let mut recovered = storage
955            .load_permission_subjects()
956            .await
957            .expect("last committed permissions should recover");
958        assert_eq!(recovered.len(), 2);
959        assert_eq!(
960            recovered
961                .get(Uuid::from_u128(10))
962                .map(PermissionSubjectState::groups),
963            Some(["builder".to_owned()].as_slice())
964        );
965        assert_eq!(
966            recovered
967                .get(Uuid::from_u128(20))
968                .map(PermissionSubjectState::groups),
969            Some(["moderator".to_owned()].as_slice())
970        );
971        assert!(!temporary.exists());
972
973        recovered.set(
974            Uuid::from_u128(30),
975            PermissionSubjectState::new(vec!["operator".to_owned()], PermissionSet::new()),
976        );
977        storage
978            .save_permission_subjects(&recovered)
979            .await
980            .expect("an update after recovery should preserve existing subjects");
981        let updated = storage
982            .load_permission_subjects()
983            .await
984            .expect("updated permissions should load");
985        assert_eq!(updated.len(), 3);
986
987        fs::remove_dir_all(root)
988            .await
989            .expect("temporary storage should be removable");
990    }
991
992    #[tokio::test]
993    async fn corrupt_live_permission_file_does_not_fall_back_to_its_backup() {
994        let root = temp_storage_root("corrupt-live-permissions");
995        let storage = FilePlayerDataStorage::new(root.clone())
996            .await
997            .expect("test storage should initialize");
998        let mut subjects = PermissionSubjectIndex::new();
999        subjects.set(
1000            Uuid::from_u128(42),
1001            PermissionSubjectState::new(vec!["op".to_owned()], PermissionSet::new()),
1002        );
1003        storage
1004            .save_permission_subjects(&subjects)
1005            .await
1006            .expect("permission subject should persist");
1007        let path = storage.player_permissions_file();
1008        let backup = FilePlayerDataStorage::atomic_backup_path(&path);
1009        fs::copy(&path, &backup)
1010            .await
1011            .expect("valid backup should be staged");
1012        fs::write(&path, b"not valid permission TOML")
1013            .await
1014            .expect("live permission file should be corrupted for the test");
1015
1016        let error = storage
1017            .load_permission_subjects()
1018            .await
1019            .expect_err("a corrupt live permission file must remain startup-fatal");
1020        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1021        assert_eq!(
1022            fs::read_to_string(&path)
1023                .await
1024                .expect("corrupt live file should remain in place"),
1025            "not valid permission TOML"
1026        );
1027
1028        fs::remove_dir_all(root)
1029            .await
1030            .expect("temporary storage should be removable");
1031    }
1032
1033    #[tokio::test]
1034    async fn interrupted_first_known_player_publication_discards_its_temporary_file() {
1035        let root = temp_storage_root("known-player-interrupted-first-write");
1036        let storage = FilePlayerDataStorage::new(root.clone())
1037            .await
1038            .expect("test storage should initialize");
1039        let uuid = Uuid::from_u128(42);
1040        let players =
1041            KnownPlayers::from_entries([KnownPlayer::with_expiration(uuid, "Steve", 1_234_567)]);
1042        let path = storage.known_players_file();
1043        let temporary = FilePlayerDataStorage::atomic_temp_path(&path);
1044        let bytes = encode_known_players_file(&KnownPlayersFile::from_known_players(&players))
1045            .expect("known players should encode");
1046        fs::write(&temporary, bytes)
1047            .await
1048            .expect("first publication should reach its interrupted state");
1049
1050        let loaded = storage
1051            .load_known_players()
1052            .await
1053            .expect("uncommitted known-player state should be ignored");
1054        assert!(loaded.entries().is_empty());
1055        assert!(!path.exists());
1056        assert!(!temporary.exists());
1057
1058        fs::remove_dir_all(root)
1059            .await
1060            .expect("temporary storage should be removable");
1061    }
1062
1063    #[tokio::test]
1064    async fn corrupt_known_player_cache_loads_as_empty() {
1065        let root = temp_storage_root("corrupt-known-players");
1066        let storage = FilePlayerDataStorage::new(root.clone())
1067            .await
1068            .expect("test storage should initialize");
1069        let path = storage.known_players_file();
1070        fs::write(&path, b"not a known-player cache")
1071            .await
1072            .expect("known-player cache should be corrupted for the test");
1073
1074        let loaded = storage
1075            .load_known_players()
1076            .await
1077            .expect("a corrupt optional cache should not prevent startup");
1078        assert!(loaded.entries().is_empty());
1079        assert_eq!(
1080            fs::read(&path)
1081                .await
1082                .expect("the corrupt cache should remain available for diagnosis"),
1083            b"not a known-player cache"
1084        );
1085
1086        fs::remove_dir_all(root)
1087            .await
1088            .expect("temporary storage should be removable");
1089    }
1090
1091    #[tokio::test]
1092    async fn incompatible_known_player_cache_version_loads_as_empty() {
1093        let root = temp_storage_root("incompatible-known-player-version");
1094        let storage = FilePlayerDataStorage::new(root.clone())
1095            .await
1096            .expect("test storage should initialize");
1097        let uuid = Uuid::from_u128(42);
1098        let players = KnownPlayers::from_entries([KnownPlayer::new(uuid, "Steve")]);
1099        let mut bytes = encode_known_players_file(&KnownPlayersFile::from_known_players(&players))
1100            .expect("known-player cache should encode");
1101        bytes[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
1102        fs::write(storage.known_players_file(), bytes)
1103            .await
1104            .expect("incompatible known-player cache should be seeded");
1105
1106        let loaded = storage
1107            .load_known_players()
1108            .await
1109            .expect("an incompatible optional cache should not prevent startup");
1110        assert!(loaded.entries().is_empty());
1111        assert!(loaded.by_uuid(uuid).is_none());
1112
1113        fs::remove_dir_all(root)
1114            .await
1115            .expect("temporary storage should be removable");
1116    }
1117
1118    #[tokio::test]
1119    async fn interrupted_first_permission_publication_does_not_apply_uncommitted_access() {
1120        let root = temp_storage_root("permission-interrupted-first-write");
1121        let storage = FilePlayerDataStorage::new(root.clone())
1122            .await
1123            .expect("test storage should initialize");
1124        let path = storage.player_permissions_file();
1125        let temporary = FilePlayerDataStorage::atomic_temp_path(&path);
1126        let mut file = PlayerPermissionsFile::default();
1127        set_permission_subject(
1128            &mut file,
1129            Uuid::from_u128(42),
1130            &PermissionSubjectState::new(vec!["op".to_owned()], PermissionSet::new()),
1131        );
1132        let contents = serialize_player_permissions_file(&file)
1133            .expect("uncommitted permissions should serialize");
1134        fs::write(&temporary, contents)
1135            .await
1136            .expect("uncommitted permissions should be staged");
1137
1138        let loaded = storage
1139            .load_permission_subjects()
1140            .await
1141            .expect("uncommitted permissions should be ignored");
1142        assert!(loaded.is_empty());
1143        assert!(!path.exists());
1144        assert!(!temporary.exists());
1145
1146        fs::remove_dir_all(root)
1147            .await
1148            .expect("temporary storage should be removable");
1149    }
1150
1151    #[tokio::test]
1152    async fn known_player_cache_round_trips_and_rejects_stale_writes() {
1153        let root = temp_storage_root("known-players");
1154        let storage = match FilePlayerDataStorage::new(root.clone()).await {
1155            Ok(storage) => storage,
1156            Err(error) => panic!("test storage should initialize: {error}"),
1157        };
1158        let uuid = Uuid::from_u128(42);
1159        let players =
1160            KnownPlayers::from_entries([KnownPlayer::with_expiration(uuid, "Steve", 1_234_567)]);
1161
1162        let stale = storage
1163            .save_known_players_if_current(&players, || false)
1164            .await;
1165        assert!(matches!(stale, Ok(false)));
1166        assert!(!storage.known_players_file().exists());
1167
1168        let saved = storage
1169            .save_known_players_if_current(&players, || true)
1170            .await;
1171        assert!(matches!(saved, Ok(true)));
1172        let loaded = storage.load_known_players().await;
1173        let Ok(loaded) = loaded else {
1174            panic!("known players should load");
1175        };
1176        assert_eq!(
1177            loaded.by_uuid(uuid).map(KnownPlayer::last_known_name),
1178            Some("Steve")
1179        );
1180        assert_eq!(
1181            loaded.by_uuid(uuid).map(KnownPlayer::expires_at_millis),
1182            Some(1_234_567)
1183        );
1184
1185        fs::remove_dir_all(root)
1186            .await
1187            .expect("temporary storage should be removable");
1188    }
1189
1190    #[test]
1191    fn known_player_cache_persists_vanillas_mru_limit() {
1192        let players = KnownPlayers::from_entries((0_u128..=1_000).map(|value| {
1193            KnownPlayer::with_expiration(
1194                Uuid::from_u128(value),
1195                format!("Player{value}"),
1196                1_234_567,
1197            )
1198        }));
1199        let encoded = encode_known_players_file(&KnownPlayersFile::from_known_players(&players));
1200        let Ok(encoded) = encoded else {
1201            panic!("known player cache should encode");
1202        };
1203        let decoded =
1204            decode_known_players_file(&encoded).and_then(KnownPlayersFile::into_known_players);
1205        let Ok(decoded) = decoded else {
1206            panic!("known player cache should decode");
1207        };
1208
1209        assert_eq!(decoded.entries().len(), 1_000);
1210        assert!(decoded.by_uuid(Uuid::from_u128(999)).is_some());
1211        assert!(decoded.by_uuid(Uuid::from_u128(1_000)).is_none());
1212    }
1213
1214    #[test]
1215    fn player_file_roundtrip_preserves_domain_world_data() {
1216        let file = sample_player_file(PLAYER_DATA_VERSION);
1217
1218        let encoded = encode_player_file(&file).expect("player file should encode");
1219        let decoded = decode_player_file(&encoded).expect("player file should decode");
1220
1221        assert_eq!(
1222            u16::from_le_bytes([encoded[4], encoded[5]]),
1223            PLAYER_STORAGE_VERSION
1224        );
1225        assert_eq!(decoded.world, "lobby:void");
1226        assert_eq!(decoded.game_mode, 2);
1227        assert_eq!(decoded.selected_slot, 4);
1228        assert_eq!(decoded.experience_level, 7);
1229        assert_eq!(decoded.experience_progress.to_bits(), 0.5_f32.to_bits());
1230        assert_eq!(decoded.experience_total, 32);
1231        assert_eq!(decoded.score, 9);
1232        assert!(decoded.seen_credits);
1233    }
1234
1235    #[test]
1236    fn player_file_roundtrip_preserves_absent_previous_game_mode() {
1237        let mut file = sample_player_file(PLAYER_DATA_VERSION);
1238        file.prev_game_mode = None;
1239
1240        let encoded = encode_player_file(&file).expect("player file should encode");
1241        let decoded = decode_player_file(&encoded).expect("player file should decode");
1242        let persistent = decoded
1243            .into_persistent()
1244            .expect("player file should convert");
1245
1246        assert_eq!(persistent.prev_game_mode, None);
1247    }
1248
1249    #[test]
1250    fn global_file_roundtrip_preserves_last_active_domain() {
1251        let file = GlobalPlayerDataFile {
1252            data_version: GLOBAL_PLAYER_DATA_VERSION,
1253            last_active_domain: "minecraft".to_owned(),
1254        };
1255
1256        let encoded = encode_global_file(&file).expect("global file should encode");
1257        let decoded = decode_global_file(&encoded).expect("global file should decode");
1258
1259        assert_eq!(
1260            u16::from_le_bytes([encoded[4], encoded[5]]),
1261            GLOBAL_STORAGE_VERSION
1262        );
1263        assert_eq!(decoded.last_active_domain, "minecraft");
1264    }
1265
1266    #[tokio::test]
1267    async fn permission_subject_snapshot_removes_noncanonical_uuid_key() {
1268        let root = temp_storage_root("permission-uuid-key");
1269        let storage = match FilePlayerDataStorage::new(root.clone()).await {
1270            Ok(storage) => storage,
1271            Err(error) => panic!("test storage should initialize: {error}"),
1272        };
1273        let target_uuid = Uuid::from_u128(42);
1274        let control_uuid = Uuid::from_u128(84);
1275        let mut seed = PermissionSubjectIndex::new();
1276        seed.set(
1277            target_uuid,
1278            PermissionSubjectState::new(vec!["op".to_owned()], PermissionSet::new()),
1279        );
1280        seed.set(
1281            control_uuid,
1282            PermissionSubjectState::new(vec!["builder".to_owned()], PermissionSet::new()),
1283        );
1284        let file = PlayerPermissionsFile::from_subject_index(&seed);
1285        let canonical = target_uuid.to_string();
1286        let noncanonical = target_uuid.simple().to_string();
1287        let contents = serialize_player_permissions_file(&file)
1288            .expect("permission subjects should serialize")
1289            .replace(&canonical, &noncanonical);
1290        fs::write(storage.player_permissions_file(), contents)
1291            .await
1292            .expect("noncanonical permission UUID should be seeded");
1293
1294        let mut subjects = storage
1295            .load_permission_subjects()
1296            .await
1297            .expect("valid UUID spellings should load");
1298        assert_eq!(subjects.len(), 2);
1299        let removed = subjects
1300            .remove(target_uuid)
1301            .expect("target should be indexed by UUID");
1302        assert_eq!(removed.groups(), ["op"]);
1303        storage
1304            .save_permission_subjects(&subjects)
1305            .await
1306            .expect("updated UUID index should persist");
1307
1308        let reloaded = storage
1309            .load_permission_subjects()
1310            .await
1311            .expect("updated permission subjects should load");
1312        assert!(reloaded.get(target_uuid).is_none());
1313        assert_eq!(
1314            reloaded
1315                .get(control_uuid)
1316                .map(PermissionSubjectState::groups),
1317            Some(["builder".to_owned()].as_slice())
1318        );
1319        let persisted = fs::read_to_string(storage.player_permissions_file())
1320            .await
1321            .expect("updated permissions should be readable");
1322        assert!(!persisted.contains(&canonical));
1323        assert!(!persisted.contains(&noncanonical));
1324
1325        fs::remove_dir_all(root)
1326            .await
1327            .expect("temporary storage should be removable");
1328    }
1329
1330    #[test]
1331    fn player_file_roundtrip_preserves_root_vehicle() {
1332        let mut file = sample_player_file(PLAYER_DATA_VERSION);
1333        file.root_vehicle = Some(RootVehicleFile {
1334            attach: [3; 16],
1335            entity: sample_persistent_entity(),
1336        });
1337
1338        let encoded = encode_player_file(&file).expect("player file should encode");
1339        let decoded = decode_player_file(&encoded).expect("player file should decode");
1340        let persistent = decoded
1341            .into_persistent()
1342            .expect("player file should convert");
1343
1344        let Some(root_vehicle) = persistent.root_vehicle else {
1345            panic!("root vehicle should survive roundtrip");
1346        };
1347        assert_eq!(root_vehicle.attach, [3; 16]);
1348        assert_eq!(root_vehicle.entity.uuid, [7; 16]);
1349        assert_eq!(
1350            root_vehicle.entity.entity_type,
1351            Identifier::vanilla_static("minecart")
1352        );
1353        assert_eq!(
1354            root_vehicle.entity.pos.map(f64::to_bits),
1355            [4.0_f64.to_bits(), 65.0_f64.to_bits(), 6.0_f64.to_bits()]
1356        );
1357    }
1358
1359    #[test]
1360    fn player_file_roundtrip_preserves_ender_pearls() {
1361        let mut file = sample_player_file(PLAYER_DATA_VERSION);
1362        file.ender_pearls = vec![
1363            EnderPearlFile {
1364                world: "minecraft:overworld".to_owned(),
1365                entity: sample_persistent_entity(),
1366            },
1367            EnderPearlFile {
1368                world: "minecraft:the_nether".to_owned(),
1369                entity: sample_persistent_entity(),
1370            },
1371        ];
1372
1373        let encoded = encode_player_file(&file).expect("player file should encode");
1374        let decoded = decode_player_file(&encoded).expect("player file should decode");
1375        let persistent = decoded
1376            .into_persistent()
1377            .expect("player file should convert");
1378
1379        assert_eq!(persistent.ender_pearls.len(), 2);
1380        assert_eq!(persistent.ender_pearls[0].world, "minecraft:overworld");
1381        assert_eq!(persistent.ender_pearls[1].world, "minecraft:the_nether");
1382        assert_eq!(persistent.ender_pearls[0].entity.uuid, [7; 16]);
1383        assert_eq!(
1384            persistent.ender_pearls[0].entity.pos.map(f64::to_bits),
1385            [4.0_f64.to_bits(), 65.0_f64.to_bits(), 6.0_f64.to_bits()]
1386        );
1387    }
1388
1389    #[test]
1390    fn stale_player_payload_version_is_rejected() {
1391        let file = sample_player_file(PLAYER_DATA_VERSION - 1);
1392
1393        let error = file
1394            .into_persistent()
1395            .expect_err("stale payload should fail");
1396
1397        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1398    }
1399}