Skip to main content

steel_core/player/player_data_storage/
file_storage.rs

1use super::known_players::{
2    KnownPlayersFile, decode_known_players_file, encode_known_players_file,
3};
4use super::permissions::{PlayerPermissionsFile, serialize_player_permissions_file};
5use super::stats::{PlayerStatsFile, serialize_player_stats_file};
6use super::{
7    GLOBAL_PLAYER_DATA_VERSION, GlobalPlayerData, GlobalPlayerDataFile, PlayerDataFile,
8    decode_global_file, decode_player_file, encode_global_file, encode_player_file,
9};
10use crate::permission::PermissionSubjectIndex;
11use crate::player::advancement::PersistedAdvancements;
12use crate::player::advancement::PersistedAdvancementsRef;
13use crate::player::advancement::progress::AdvancementProgress;
14use crate::player::player_data::{PersistentPlayerData, PersistentStat};
15use crate::player::{KnownPlayers, Player};
16use rustc_hash::FxHashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use steel_utils::Identifier;
20use steel_utils::locks::{AsyncMutex, SyncMutex};
21use tokio::io::AsyncWriteExt;
22use tokio::sync::OwnedMutexGuard as OwnedAsyncMutexGuard;
23use tokio::{fs, io};
24use uuid::Uuid;
25
26pub(crate) struct FilePlayerDataStorage {
27    save_root: PathBuf,
28    file_locks: SyncMutex<FxHashMap<PathBuf, Arc<AsyncMutex<()>>>>,
29}
30
31impl FilePlayerDataStorage {
32    pub(crate) async fn new(save_root: PathBuf) -> io::Result<Self> {
33        fs::create_dir_all(save_root.join("global").join("players")).await?;
34        Ok(Self {
35            save_root,
36            file_locks: SyncMutex::new(FxHashMap::default()),
37        })
38    }
39
40    pub(crate) async fn save_domain(&self, domain: &str, player: &Player) -> io::Result<()> {
41        let uuid = player.gameprofile.id();
42        let data = PersistentPlayerData::from_player(player);
43        self.save_domain_data(domain, uuid, &data).await
44    }
45
46    pub(crate) async fn save_domain_data(
47        &self,
48        domain: &str,
49        uuid: Uuid,
50        data: &PersistentPlayerData,
51    ) -> io::Result<()> {
52        self.save_domain_player_data(domain, uuid, data).await?;
53        self.save_domain_player_stats(domain, uuid, data).await?;
54        self.save_domain_player_advancements(domain, uuid, data)
55            .await?;
56        Ok(())
57    }
58
59    pub(crate) async fn save_domain_player_data(
60        &self,
61        domain: &str,
62        uuid: Uuid,
63        data: &PersistentPlayerData,
64    ) -> io::Result<()> {
65        let file = PlayerDataFile::from_persistent(data)?;
66        let bytes = encode_player_file(&file)?;
67        self.write_atomic_player_data(&self.domain_players_dir(domain), uuid, &bytes)
68            .await?;
69        log::debug!("Saved player data for {uuid} in domain {domain}");
70
71        Ok(())
72    }
73
74    pub(crate) async fn save_domain_player_stats(
75        &self,
76        domain: &str,
77        uuid: Uuid,
78        data: &PersistentPlayerData,
79    ) -> io::Result<()> {
80        let player_stats_file = PlayerStatsFile::from_persistent_stats(&data.stats)?;
81        let toml_string = serialize_player_stats_file(&player_stats_file);
82        let final_path = Self::player_stats_file(&self.domain_players_dir(domain), uuid);
83        let _guard = self.file_lock(&final_path).await;
84        Self::write_atomic_path_locked(&final_path, toml_string.as_bytes()).await?;
85        log::debug!("Saved player stats for {uuid} in domain {domain}");
86
87        Ok(())
88    }
89
90    pub(crate) async fn save_domain_player_advancements(
91        &self,
92        domain: &str,
93        uuid: Uuid,
94        data: &PersistentPlayerData,
95    ) -> io::Result<()> {
96        let file = PersistedAdvancementsRef {
97            entries: &data.advancements,
98        };
99        let json = serde_json::to_vec_pretty(&file)
100            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
101        let final_path = Self::player_advancements_file(&self.domain_players_dir(domain), uuid);
102        let _guard = self.file_lock(&final_path).await;
103        Self::write_atomic_path_locked(&final_path, &json).await?;
104        log::debug!("Saved player advancements for {uuid} in domain {domain}");
105
106        Ok(())
107    }
108
109    pub(crate) async fn load_domain(
110        &self,
111        domain: &str,
112        uuid: Uuid,
113    ) -> io::Result<Option<PersistentPlayerData>> {
114        let Some(mut data) = self.load_domain_player_data(domain, uuid).await? else {
115            return Ok(None);
116        };
117
118        data.stats = match self.load_domain_player_stats(domain, uuid).await {
119            Ok(Some(stats)) => stats,
120            Ok(None) => {
121                log::debug!("Using empty stats for {uuid} in domain {domain}");
122                Vec::new()
123            }
124            Err(e) => {
125                log::error!("Could not load stats for {uuid} in domain {domain}: {e}");
126                Vec::new()
127            }
128        };
129
130        data.advancements = match self.load_domain_player_advancements(domain, uuid).await {
131            Ok(Some(advancements)) => advancements,
132            Ok(None) => {
133                log::debug!("Using empty advancements for {uuid} in domain {domain}");
134                Vec::new()
135            }
136            Err(e) => {
137                log::error!("Could not load advancements for {uuid} in domain {domain}: {e}");
138                Vec::new()
139            }
140        };
141
142        Ok(Some(data))
143    }
144
145    pub(crate) async fn load_domain_player_data(
146        &self,
147        domain: &str,
148        uuid: Uuid,
149    ) -> io::Result<Option<PersistentPlayerData>> {
150        let domain_dir = self.domain_players_dir(domain);
151        let path = Self::player_data_file(&domain_dir, uuid);
152        let _guard = self.file_lock(&path).await;
153        if !Self::recover_missing_atomic_path_locked(&path).await? {
154            return Ok(None);
155        }
156        let bytes = fs::read(&path).await?;
157        let file = decode_player_file(&bytes)?;
158        let data = file.into_persistent()?;
159        log::debug!("Loaded player data for {uuid} in domain {domain}");
160
161        Ok(Some(data))
162    }
163
164    pub(crate) async fn load_domain_player_stats(
165        &self,
166        domain: &str,
167        uuid: Uuid,
168    ) -> io::Result<Option<Vec<PersistentStat>>> {
169        let domain_dir = self.domain_players_dir(domain);
170        let path = Self::player_stats_file(&domain_dir, uuid);
171        let _guard = self.file_lock(&path).await;
172        if !Self::recover_missing_atomic_path_locked(&path).await? {
173            return Ok(None);
174        }
175
176        let string = fs::read_to_string(&path).await?;
177        let stats_file: PlayerStatsFile = toml::from_str(&string).map_err(|e| {
178            io::Error::new(
179                io::ErrorKind::InvalidData,
180                format!("Invalid player stats for {uuid} in domain {domain}: {e}"),
181            )
182        })?;
183        let persistent_stats = stats_file.into_persistent_stats();
184        log::debug!("Loaded player stats for {uuid} in domain {domain}");
185
186        Ok(Some(persistent_stats))
187    }
188
189    pub(crate) async fn load_domain_player_advancements(
190        &self,
191        domain: &str,
192        uuid: Uuid,
193    ) -> io::Result<Option<Vec<(Identifier, AdvancementProgress)>>> {
194        let domain_dir = self.domain_players_dir(domain);
195        let path = Self::player_advancements_file(&domain_dir, uuid);
196        let _guard = self.file_lock(&path).await;
197        if !Self::recover_missing_atomic_path_locked(&path).await? {
198            return Ok(None);
199        }
200
201        let bytes = fs::read(&path).await?;
202        let file: PersistedAdvancements = serde_json::from_slice(&bytes).map_err(|e| {
203            io::Error::new(
204                io::ErrorKind::InvalidData,
205                format!("Invalid player advancements for {uuid} in domain {domain}: {e}"),
206            )
207        })?;
208        log::debug!("Loaded player advancements for {uuid} in domain {domain}");
209
210        Ok(Some(file.entries))
211    }
212
213    pub(crate) async fn load_global(&self, uuid: Uuid) -> io::Result<Option<GlobalPlayerData>> {
214        let path = Self::player_data_file(&self.global_players_dir(), uuid);
215        let _guard = self.file_lock(&path).await;
216        if !Self::recover_missing_atomic_path_locked(&path).await? {
217            return Ok(None);
218        }
219        let bytes = fs::read(&path).await?;
220        let file = decode_global_file(&bytes)?;
221        Ok(Some(GlobalPlayerData {
222            last_active_domain: file.last_active_domain,
223        }))
224    }
225
226    pub(crate) async fn load_permission_subjects(&self) -> io::Result<PermissionSubjectIndex> {
227        self.load_player_permissions_file()
228            .await?
229            .into_subject_index()
230    }
231
232    pub(crate) async fn load_known_players(&self) -> io::Result<KnownPlayers> {
233        let path = self.known_players_file();
234        let _guard = self.file_lock(&path).await;
235        match Self::read_known_players_file_locked(&path).await {
236            Ok(players) => Ok(players),
237            Err(error) => {
238                log::warn!(
239                    "Failed to load known player cache from {}: {error}. Starting with an empty cache",
240                    path.display()
241                );
242                Ok(KnownPlayers::new())
243            }
244        }
245    }
246
247    async fn read_known_players_file_locked(path: &Path) -> io::Result<KnownPlayers> {
248        if !Self::recover_missing_atomic_path_locked(path).await? {
249            return Ok(KnownPlayers::new());
250        }
251        let bytes = fs::read(path).await?;
252        decode_known_players_file(&bytes)?.into_known_players()
253    }
254
255    pub(crate) async fn save_known_players_if_current(
256        &self,
257        players: &KnownPlayers,
258        is_current: impl FnOnce() -> bool + Send,
259    ) -> io::Result<bool> {
260        let path = self.known_players_file();
261        let _guard = self.file_lock(&path).await;
262        if !is_current() {
263            return Ok(false);
264        }
265        let bytes = encode_known_players_file(&KnownPlayersFile::from_known_players(players))?;
266        Self::write_atomic_path_locked(&path, &bytes).await?;
267        Ok(true)
268    }
269
270    pub(crate) async fn save_global(&self, uuid: Uuid, data: &GlobalPlayerData) -> io::Result<()> {
271        let file = GlobalPlayerDataFile {
272            data_version: GLOBAL_PLAYER_DATA_VERSION,
273            last_active_domain: data.last_active_domain.clone(),
274        };
275        let bytes = encode_global_file(&file)?;
276        self.write_atomic_player_data(&self.global_players_dir(), uuid, &bytes)
277            .await
278    }
279
280    pub(crate) async fn save_permission_subjects(
281        &self,
282        subjects: &PermissionSubjectIndex,
283    ) -> io::Result<()> {
284        let path = self.player_permissions_file();
285        let _guard = self.file_lock(&path).await;
286        let file = PlayerPermissionsFile::from_subject_index(subjects);
287        self.write_player_permissions_file_locked(&path, &file)
288            .await
289    }
290
291    pub(crate) async fn load_player_permissions_file(&self) -> io::Result<PlayerPermissionsFile> {
292        let path = self.player_permissions_file();
293        let _guard = self.file_lock(&path).await;
294        self.read_player_permissions_file_locked(&path).await
295    }
296
297    pub(crate) async fn read_player_permissions_file_locked(
298        &self,
299        path: &Path,
300    ) -> io::Result<PlayerPermissionsFile> {
301        if !Self::recover_missing_atomic_path_locked(path).await? {
302            return Ok(PlayerPermissionsFile::default());
303        }
304        let contents = fs::read_to_string(path).await?;
305        let file = toml::from_str::<PlayerPermissionsFile>(&contents).map_err(|error| {
306            io::Error::new(
307                io::ErrorKind::InvalidData,
308                format!(
309                    "invalid player permissions TOML in {}: {error}",
310                    path.display()
311                ),
312            )
313        })?;
314        file.validate()?;
315        Ok(file)
316    }
317
318    pub(crate) async fn write_player_permissions_file_locked(
319        &self,
320        path: &Path,
321        file: &PlayerPermissionsFile,
322    ) -> io::Result<()> {
323        let contents = serialize_player_permissions_file(file).map_err(|error| {
324            io::Error::new(
325                io::ErrorKind::InvalidData,
326                format!("failed to serialize player permissions TOML: {error}"),
327            )
328        })?;
329        Self::write_atomic_path_locked(path, contents.as_bytes()).await
330    }
331
332    pub(crate) fn global_dir(&self) -> PathBuf {
333        self.save_root.join("global")
334    }
335
336    pub(crate) fn global_players_dir(&self) -> PathBuf {
337        self.global_dir().join("players")
338    }
339
340    pub(crate) fn player_permissions_file(&self) -> PathBuf {
341        self.global_dir().join("player_permissions.toml")
342    }
343
344    pub(crate) fn known_players_file(&self) -> PathBuf {
345        self.global_dir().join("known_players.dat")
346    }
347
348    pub(crate) fn domain_players_dir(&self, domain: &str) -> PathBuf {
349        self.save_root.join(domain).join("players")
350    }
351
352    pub(crate) fn player_data_file(players_dir: &Path, uuid: Uuid) -> PathBuf {
353        players_dir.join(format!("data/{uuid}"))
354    }
355
356    pub(crate) fn player_stats_file(players_dir: &Path, uuid: Uuid) -> PathBuf {
357        players_dir.join(format!("stats/{uuid}.toml"))
358    }
359
360    pub(crate) fn player_advancements_file(players_dir: &Path, uuid: Uuid) -> PathBuf {
361        players_dir.join(format!("advancements/{uuid}.json"))
362    }
363
364    async fn file_lock(&self, path: &Path) -> OwnedAsyncMutexGuard<()> {
365        let mutex = Arc::clone(
366            self.file_locks
367                .lock()
368                .entry(path.to_path_buf())
369                .or_insert_with(|| Arc::new(AsyncMutex::new(()))),
370        );
371
372        mutex.lock_owned().await
373    }
374
375    async fn write_atomic_player_data(
376        &self,
377        players_dir: &Path,
378        uuid: Uuid,
379        bytes: &[u8],
380    ) -> io::Result<()> {
381        let final_path = Self::player_data_file(players_dir, uuid);
382        let _guard = self.file_lock(&final_path).await;
383        Self::write_atomic_path_locked(&final_path, bytes).await
384    }
385
386    pub(crate) async fn write_atomic_path_locked(
387        final_path: &Path,
388        bytes: &[u8],
389    ) -> io::Result<()> {
390        let Some(parent) = final_path.parent() else {
391            return Err(io::Error::new(
392                io::ErrorKind::InvalidInput,
393                "atomic write path has no parent",
394            ));
395        };
396        fs::create_dir_all(parent).await?;
397        let temp_path = Self::atomic_temp_path(final_path);
398        let backup_path = Self::atomic_backup_path(final_path);
399        let backup_temp_path = Self::atomic_temp_path(&backup_path);
400
401        Self::write_synced_file(&temp_path, bytes).await?;
402        if fs::try_exists(final_path).await? {
403            Self::copy_synced_file(final_path, &backup_temp_path).await?;
404            fs::rename(&backup_temp_path, &backup_path).await?;
405        }
406        fs::rename(&temp_path, final_path).await?;
407        if let Err(error) = Self::sync_parent(parent).await {
408            tracing::error!(
409                %error,
410                path = %final_path.display(),
411                "Atomic data-file replacement committed, but directory sync failed; crash durability is uncertain"
412            );
413        }
414        Ok(())
415    }
416
417    pub(crate) fn atomic_temp_path(path: &Path) -> PathBuf {
418        let extension = path.extension().and_then(|value| value.to_str());
419        path.with_extension(match extension {
420            Some(extension) => format!("{extension}.tmp"),
421            None => "tmp".to_owned(),
422        })
423    }
424
425    pub(crate) fn atomic_backup_path(path: &Path) -> PathBuf {
426        let extension = path.extension().and_then(|value| value.to_str());
427        path.with_extension(match extension {
428            Some(extension) => format!("{extension}_old"),
429            None => "old".to_owned(),
430        })
431    }
432
433    async fn recover_missing_atomic_path_locked(final_path: &Path) -> io::Result<bool> {
434        if fs::try_exists(final_path).await? {
435            return Ok(true);
436        }
437
438        let backup_path = Self::atomic_backup_path(final_path);
439        if fs::try_exists(&backup_path).await? {
440            fs::rename(&backup_path, final_path).await?;
441            let Some(parent) = final_path.parent() else {
442                return Err(io::Error::new(
443                    io::ErrorKind::InvalidInput,
444                    "atomic recovery path has no parent",
445                ));
446            };
447            Self::sync_parent(parent).await?;
448            let temp_path = Self::atomic_temp_path(final_path);
449            if fs::try_exists(&temp_path).await?
450                && let Err(error) = fs::remove_file(&temp_path).await
451            {
452                tracing::warn!(
453                    %error,
454                    path = %temp_path.display(),
455                    "Failed to remove an uncommitted atomic-write temporary file"
456                );
457            }
458            tracing::warn!(
459                path = %final_path.display(),
460                backup = %backup_path.display(),
461                "Recovered a missing data file from its last committed backup"
462            );
463            return Ok(true);
464        }
465
466        let temp_path = Self::atomic_temp_path(final_path);
467        if fs::try_exists(&temp_path).await? {
468            if let Err(error) = fs::remove_file(&temp_path).await {
469                tracing::warn!(
470                    %error,
471                    path = %temp_path.display(),
472                    "Failed to remove an uncommitted atomic-write temporary file"
473                );
474            }
475            tracing::warn!(
476                path = %final_path.display(),
477                temporary = %temp_path.display(),
478                "Discarded an interrupted data-file publication with no committed generation"
479            );
480        }
481
482        Ok(false)
483    }
484
485    async fn write_synced_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
486        let mut file = fs::File::create(path).await?;
487        file.write_all(bytes).await?;
488        file.sync_all().await
489    }
490
491    async fn copy_synced_file(source: &Path, destination: &Path) -> io::Result<()> {
492        let mut source = fs::File::open(source).await?;
493        let mut destination = fs::File::create(destination).await?;
494        io::copy(&mut source, &mut destination).await?;
495        destination.sync_all().await
496    }
497
498    async fn sync_parent(parent: &Path) -> io::Result<()> {
499        // Runtime check so the `.await`s stay present on all platforms for clippy;
500        // on Windows the branch never runs (directory fsync is unix-only).
501        if cfg!(unix) {
502            fs::File::open(parent).await?.sync_all().await?;
503        }
504        Ok(())
505    }
506}