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 file_storage;
4mod known_players;
5mod memory_storage;
6mod permissions;
7mod stats;
8
9#[cfg(test)]
10mod tests;
11
12use std::{io::Cursor, path::PathBuf};
13
14use simdnbt::{ToNbtTag, borrow::read_compound as read_borrowed_compound, owned::NbtTag};
15use tokio::io;
16use uuid::Uuid;
17use wincode::{SchemaRead, SchemaWrite};
18
19#[cfg(test)]
20use self::permissions::set_permission_subject;
21use super::PlayerRespawnConfig;
22use super::player_data::{
23    PLAYER_DATA_VERSION, PersistentAbilities, PersistentEnderPearl, PersistentPlayerData,
24    PersistentRootVehicle, PersistentSlot,
25};
26use crate::chunk_saver::PersistentEntity;
27use crate::config::StorageSelection;
28use crate::level_data::RespawnData;
29use crate::permission::PermissionSubjectIndex;
30#[cfg(test)]
31use crate::permission::PermissionSubjectState;
32use crate::player::KnownPlayers;
33use crate::player::Player;
34use crate::player::player_data_storage::file_storage::FilePlayerDataStorage;
35use crate::player::player_data_storage::memory_storage::MemoryPlayerDataStorage;
36use steel_registry::item_stack::ItemStack;
37use steel_utils::{BlockPos, Identifier};
38
39const PLAYER_MAGIC: [u8; 4] = *b"STLP";
40const GLOBAL_MAGIC: [u8; 4] = *b"STLG";
41const PLAYER_STORAGE_VERSION: u16 = 9;
42const GLOBAL_STORAGE_VERSION: u16 = 1;
43const GLOBAL_PLAYER_DATA_VERSION: i32 = 1;
44
45/// Server-wide player data.
46#[derive(Debug, Clone)]
47pub struct GlobalPlayerData {
48    /// Last active domain for reconnects.
49    pub last_active_domain: String,
50}
51
52/// Manages player data persistence.
53pub struct PlayerDataStorage {
54    backend: PlayerDataStorageBackend,
55}
56
57enum PlayerDataStorageBackend {
58    File(FilePlayerDataStorage),
59    Memory(MemoryPlayerDataStorage),
60}
61
62#[derive(SchemaWrite, SchemaRead)]
63struct PlayerDataFile {
64    data_version: i32,
65    pos: [f64; 3],
66    motion: [f64; 3],
67    rotation: [f32; 2],
68    on_ground: bool,
69    fall_flying: bool,
70    remaining_fire_ticks: i32,
71    ticks_frozen: i32,
72    is_in_powder_snow: bool,
73    was_in_powder_snow: bool,
74    has_visual_fire: bool,
75    health: f32,
76    game_mode: i32,
77    prev_game_mode: Option<i32>,
78    abilities: AbilitiesFile,
79    inventory: Vec<SlotFile>,
80    selected_slot: i32,
81    world: String,
82    food_level: i32,
83    food_saturation_level: f32,
84    food_exhaustion_level: f32,
85    food_tick_timer: i32,
86    experience_level: i32,
87    experience_progress: f32,
88    experience_total: i32,
89    score: i32,
90    seen_credits: bool,
91    root_vehicle: Option<RootVehicleFile>,
92    respawn_config: Option<RespawnConfigFile>,
93    ender_pearls: Vec<EnderPearlFile>,
94    ender_items: Vec<SlotFile>,
95}
96
97#[derive(SchemaWrite, SchemaRead)]
98struct RootVehicleFile {
99    attach: [u8; 16],
100    entity: PersistentEntity,
101}
102
103#[derive(SchemaWrite, SchemaRead)]
104struct RespawnConfigFile {
105    dimension: String,
106    pos: [i32; 3],
107    yaw: f32,
108    pitch: f32,
109    forced: bool,
110}
111
112#[derive(SchemaWrite, SchemaRead)]
113struct EnderPearlFile {
114    world: String,
115    entity: PersistentEntity,
116}
117
118#[derive(SchemaWrite, SchemaRead)]
119struct AbilitiesFile {
120    invulnerable: bool,
121    flying: bool,
122    may_fly: bool,
123    instabuild: bool,
124    may_build: bool,
125    flying_speed: f32,
126    walking_speed: f32,
127}
128
129#[derive(SchemaWrite, SchemaRead)]
130struct SlotFile {
131    slot: i8,
132    item_nbt: Vec<u8>,
133}
134
135#[derive(SchemaWrite, SchemaRead)]
136struct GlobalPlayerDataFile {
137    data_version: i32,
138    last_active_domain: String,
139}
140
141impl PlayerDataStorage {
142    /// Creates the player data storage a config selection asks for.
143    ///
144    /// `save_root` is only used by backends that persist.
145    pub async fn from_selection(
146        save_root: PathBuf,
147        selection: &StorageSelection,
148    ) -> io::Result<Self> {
149        if selection.kind == Identifier::from_steel("file") {
150            Self::on_disk(save_root).await
151        } else if selection.kind == Identifier::from_steel("ram") {
152            Ok(Self::in_memory())
153        } else {
154            Err(io::Error::new(
155                io::ErrorKind::InvalidInput,
156                format!("unknown player storage {}", selection.kind),
157            ))
158        }
159    }
160
161    /// Creates player data storage backed by files under `save_root`.
162    pub async fn on_disk(save_root: PathBuf) -> io::Result<Self> {
163        let backend = PlayerDataStorageBackend::File(FilePlayerDataStorage::new(save_root).await?);
164        Ok(Self { backend })
165    }
166
167    /// Creates player data storage that keeps everything in RAM.
168    #[must_use]
169    pub fn in_memory() -> Self {
170        Self {
171            backend: PlayerDataStorageBackend::Memory(MemoryPlayerDataStorage::default()),
172        }
173    }
174
175    /// Saves a player's current domain data and global last-active-domain.
176    pub async fn save(&self, player: &Player) -> io::Result<()> {
177        let domain = player.get_world().domain().to_owned();
178        self.save_domain(&domain, player).await?;
179        self.save_global(
180            player.gameprofile.id,
181            &GlobalPlayerData {
182                last_active_domain: domain,
183            },
184        )
185        .await
186    }
187
188    /// Saves a player's data for a specific domain.
189    pub async fn save_domain(&self, domain: &str, player: &Player) -> io::Result<()> {
190        match &self.backend {
191            PlayerDataStorageBackend::File(storage) => storage.save_domain(domain, player).await,
192            PlayerDataStorageBackend::Memory(storage) => {
193                storage.save_domain(domain, player);
194                Ok(())
195            }
196        }
197    }
198
199    /// Saves an already captured player data snapshot for a specific domain.
200    pub async fn save_domain_data(
201        &self,
202        domain: &str,
203        uuid: Uuid,
204        data: &PersistentPlayerData,
205    ) -> io::Result<()> {
206        match &self.backend {
207            PlayerDataStorageBackend::File(storage) => {
208                storage.save_domain_data(domain, uuid, data).await
209            }
210            PlayerDataStorageBackend::Memory(storage) => {
211                storage.save_domain_data(domain, uuid, data);
212                Ok(())
213            }
214        }
215    }
216
217    /// Loads a player's data for a specific domain.
218    pub async fn load_domain(
219        &self,
220        domain: &str,
221        uuid: Uuid,
222    ) -> io::Result<Option<PersistentPlayerData>> {
223        match &self.backend {
224            PlayerDataStorageBackend::File(storage) => storage.load_domain(domain, uuid).await,
225            PlayerDataStorageBackend::Memory(storage) => Ok(storage.load_domain(domain, uuid)),
226        }
227    }
228
229    /// Loads server-wide player data.
230    pub async fn load_global(&self, uuid: Uuid) -> io::Result<Option<GlobalPlayerData>> {
231        match &self.backend {
232            PlayerDataStorageBackend::File(storage) => storage.load_global(uuid).await,
233            PlayerDataStorageBackend::Memory(storage) => Ok(storage.load_global(uuid)),
234        }
235    }
236
237    /// Loads all persisted player permission snapshots.
238    pub async fn load_permission_subjects(&self) -> io::Result<PermissionSubjectIndex> {
239        match &self.backend {
240            PlayerDataStorageBackend::File(storage) => storage.load_permission_subjects().await,
241            PlayerDataStorageBackend::Memory(storage) => Ok(storage.load_permission_subjects()),
242        }
243    }
244
245    /// Loads the rebuildable player identity cache, falling back to empty on failure.
246    pub async fn load_known_players(&self) -> io::Result<KnownPlayers> {
247        match &self.backend {
248            PlayerDataStorageBackend::File(storage) => storage.load_known_players().await,
249            PlayerDataStorageBackend::Memory(storage) => Ok(storage.load_known_players()),
250        }
251    }
252
253    /// Persists the identity cache when the caller's snapshot is still current.
254    pub async fn save_known_players_if_current(
255        &self,
256        players: &KnownPlayers,
257        is_current: impl FnOnce() -> bool + Send,
258    ) -> io::Result<bool> {
259        match &self.backend {
260            PlayerDataStorageBackend::File(storage) => {
261                storage
262                    .save_known_players_if_current(players, is_current)
263                    .await
264            }
265            PlayerDataStorageBackend::Memory(storage) => {
266                Ok(storage.save_known_players_if_current(players, is_current))
267            }
268        }
269    }
270
271    /// Saves server-wide player data.
272    pub async fn save_global(&self, uuid: Uuid, data: &GlobalPlayerData) -> io::Result<()> {
273        match &self.backend {
274            PlayerDataStorageBackend::File(storage) => storage.save_global(uuid, data).await,
275            PlayerDataStorageBackend::Memory(storage) => {
276                storage.save_global(uuid, data);
277                Ok(())
278            }
279        }
280    }
281
282    /// Persists the server's complete UUID-keyed permission snapshot.
283    pub async fn save_permission_subjects(
284        &self,
285        subjects: &PermissionSubjectIndex,
286    ) -> io::Result<()> {
287        match &self.backend {
288            PlayerDataStorageBackend::File(storage) => {
289                storage.save_permission_subjects(subjects).await
290            }
291            PlayerDataStorageBackend::Memory(storage) => {
292                storage.save_permission_subjects(subjects);
293                Ok(())
294            }
295        }
296    }
297}
298
299impl PlayerDataFile {
300    fn from_persistent(data: &PersistentPlayerData) -> io::Result<Self> {
301        let mut inventory = Vec::with_capacity(data.inventory.len());
302        for slot in &data.inventory {
303            inventory.push(SlotFile {
304                slot: slot.slot,
305                item_nbt: item_to_nbt_bytes(&slot.item)?,
306            });
307        }
308
309        Ok(Self {
310            data_version: data.data_version,
311            pos: data.pos,
312            motion: data.motion,
313            rotation: data.rotation,
314            on_ground: data.on_ground,
315            fall_flying: data.fall_flying,
316            remaining_fire_ticks: data.remaining_fire_ticks,
317            ticks_frozen: data.ticks_frozen,
318            is_in_powder_snow: data.is_in_powder_snow,
319            was_in_powder_snow: data.was_in_powder_snow,
320            has_visual_fire: data.has_visual_fire,
321            health: data.health,
322            game_mode: data.game_mode,
323            prev_game_mode: data.prev_game_mode,
324            abilities: AbilitiesFile {
325                invulnerable: data.abilities.invulnerable,
326                flying: data.abilities.flying,
327                may_fly: data.abilities.may_fly,
328                instabuild: data.abilities.instabuild,
329                may_build: data.abilities.may_build,
330                flying_speed: data.abilities.flying_speed,
331                walking_speed: data.abilities.walking_speed,
332            },
333            inventory,
334            selected_slot: data.selected_slot,
335            world: data.world.clone(),
336            food_level: data.food_level,
337            food_saturation_level: data.food_saturation_level,
338            food_exhaustion_level: data.food_exhaustion_level,
339            food_tick_timer: data.food_tick_timer,
340            experience_level: data.experience_level,
341            experience_progress: data.experience_progress,
342            experience_total: data.experience_total,
343            score: data.score,
344            seen_credits: data.seen_credits,
345            root_vehicle: data
346                .root_vehicle
347                .clone()
348                .map(|root_vehicle| RootVehicleFile {
349                    attach: root_vehicle.attach,
350                    entity: root_vehicle.entity,
351                }),
352            respawn_config: data
353                .respawn_config
354                .clone()
355                .map(RespawnConfigFile::from_runtime),
356            ender_pearls: data
357                .ender_pearls
358                .iter()
359                .map(|pearl| EnderPearlFile {
360                    world: pearl.world.clone(),
361                    entity: pearl.entity.clone(),
362                })
363                .collect(),
364            ender_items: data
365                .ender_items
366                .iter()
367                .map(|slot| SlotFile {
368                    slot: slot.slot,
369                    item_nbt: item_to_nbt_bytes(&slot.item).unwrap_or_default(),
370                })
371                .collect(),
372        })
373    }
374
375    fn into_persistent(self) -> io::Result<PersistentPlayerData> {
376        if self.data_version != PLAYER_DATA_VERSION {
377            return Err(io::Error::new(
378                io::ErrorKind::InvalidData,
379                format!(
380                    "unsupported player data payload version {}",
381                    self.data_version
382                ),
383            ));
384        }
385
386        let mut inventory = Vec::with_capacity(self.inventory.len());
387        for slot in self.inventory {
388            inventory.push(PersistentSlot {
389                slot: slot.slot,
390                item: item_from_nbt_bytes(&slot.item_nbt)?,
391            });
392        }
393
394        Ok(PersistentPlayerData {
395            pos: self.pos,
396            motion: self.motion,
397            rotation: self.rotation,
398            on_ground: self.on_ground,
399            fall_flying: self.fall_flying,
400            remaining_fire_ticks: self.remaining_fire_ticks,
401            ticks_frozen: self.ticks_frozen,
402            is_in_powder_snow: self.is_in_powder_snow,
403            was_in_powder_snow: self.was_in_powder_snow,
404            has_visual_fire: self.has_visual_fire,
405            health: self.health,
406            game_mode: self.game_mode,
407            prev_game_mode: self.prev_game_mode,
408            abilities: PersistentAbilities {
409                invulnerable: self.abilities.invulnerable,
410                flying: self.abilities.flying,
411                may_fly: self.abilities.may_fly,
412                instabuild: self.abilities.instabuild,
413                may_build: self.abilities.may_build,
414                flying_speed: self.abilities.flying_speed,
415                walking_speed: self.abilities.walking_speed,
416            },
417            inventory,
418            selected_slot: self.selected_slot,
419            world: self.world,
420            food_level: self.food_level,
421            food_saturation_level: self.food_saturation_level,
422            food_exhaustion_level: self.food_exhaustion_level,
423            food_tick_timer: self.food_tick_timer,
424            data_version: self.data_version,
425            experience_level: self.experience_level,
426            experience_progress: self.experience_progress,
427            experience_total: self.experience_total,
428            score: self.score,
429            seen_credits: self.seen_credits,
430            root_vehicle: self.root_vehicle.map(|root_vehicle| PersistentRootVehicle {
431                attach: root_vehicle.attach,
432                entity: root_vehicle.entity,
433            }),
434            respawn_config: self
435                .respawn_config
436                .map(RespawnConfigFile::into_runtime)
437                .transpose()?,
438            ender_pearls: self
439                .ender_pearls
440                .into_iter()
441                .map(|pearl| PersistentEnderPearl {
442                    world: pearl.world,
443                    entity: pearl.entity,
444                })
445                .collect(),
446            ender_items: {
447                let mut items = Vec::with_capacity(self.ender_items.len());
448                for slot in self.ender_items {
449                    items.push(PersistentSlot {
450                        slot: slot.slot,
451                        item: item_from_nbt_bytes(&slot.item_nbt)?,
452                    });
453                }
454                items
455            },
456            stats: Vec::new(),
457        })
458    }
459}
460
461impl RespawnConfigFile {
462    fn from_runtime(config: PlayerRespawnConfig) -> Self {
463        let pos = config.respawn_data.pos();
464        Self {
465            dimension: config.respawn_data.dimension().to_string(),
466            pos: [pos.x(), pos.y(), pos.z()],
467            yaw: config.respawn_data.yaw,
468            pitch: config.respawn_data.pitch,
469            forced: config.forced,
470        }
471    }
472
473    fn into_runtime(self) -> io::Result<PlayerRespawnConfig> {
474        Ok(PlayerRespawnConfig {
475            respawn_data: RespawnData::of(
476                self.dimension.parse().map_err(|error| {
477                    io::Error::new(
478                        io::ErrorKind::InvalidData,
479                        format!("invalid respawn dimension: {error}"),
480                    )
481                })?,
482                BlockPos::new(self.pos[0], self.pos[1], self.pos[2]),
483                self.yaw,
484                self.pitch,
485            ),
486            forced: self.forced,
487        })
488    }
489}
490
491fn item_to_nbt_bytes(item: &ItemStack) -> io::Result<Vec<u8>> {
492    let NbtTag::Compound(compound) = item.clone().to_nbt_tag() else {
493        return Err(io::Error::new(
494            io::ErrorKind::InvalidData,
495            "item stack did not serialize to a compound",
496        ));
497    };
498    let mut bytes = Vec::new();
499    compound.write(&mut bytes);
500    Ok(bytes)
501}
502
503fn item_from_nbt_bytes(bytes: &[u8]) -> io::Result<ItemStack> {
504    let nbt = read_borrowed_compound(&mut Cursor::new(bytes)).map_err(|e| {
505        io::Error::new(
506            io::ErrorKind::InvalidData,
507            format!("failed to parse item NBT: {e}"),
508        )
509    })?;
510    let compound = simdnbt::borrow::NbtCompound::from(&nbt);
511    ItemStack::from_borrowed_compound(&compound)
512        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid item stack data"))
513}
514
515fn encode_player_file(file: &PlayerDataFile) -> io::Result<Vec<u8>> {
516    encode_file(
517        PLAYER_MAGIC,
518        PLAYER_STORAGE_VERSION,
519        wincode::serialize(file),
520    )
521}
522
523fn decode_player_file(bytes: &[u8]) -> io::Result<PlayerDataFile> {
524    let payload = decode_file(PLAYER_MAGIC, PLAYER_STORAGE_VERSION, bytes)?;
525    wincode::deserialize(&payload)
526        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
527}
528
529fn encode_global_file(file: &GlobalPlayerDataFile) -> io::Result<Vec<u8>> {
530    encode_file(
531        GLOBAL_MAGIC,
532        GLOBAL_STORAGE_VERSION,
533        wincode::serialize(file),
534    )
535}
536
537fn decode_global_file(bytes: &[u8]) -> io::Result<GlobalPlayerDataFile> {
538    let payload = decode_file(GLOBAL_MAGIC, GLOBAL_STORAGE_VERSION, bytes)?;
539    wincode::deserialize(&payload)
540        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
541}
542
543fn encode_file(
544    magic: [u8; 4],
545    version: u16,
546    serialized: wincode::WriteResult<Vec<u8>>,
547) -> io::Result<Vec<u8>> {
548    let payload =
549        serialized.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
550    let compressed = zstd::encode_all(&payload[..], 3)?;
551    let mut bytes = Vec::with_capacity(6 + compressed.len());
552    bytes.extend_from_slice(&magic);
553    bytes.extend_from_slice(&version.to_le_bytes());
554    bytes.extend_from_slice(&compressed);
555    Ok(bytes)
556}
557
558fn decode_file(
559    expected_magic: [u8; 4],
560    expected_version: u16,
561    bytes: &[u8],
562) -> io::Result<Vec<u8>> {
563    if bytes.len() < 6 {
564        return Err(io::Error::new(
565            io::ErrorKind::InvalidData,
566            "player data file is too short",
567        ));
568    }
569    if bytes[0..4] != expected_magic {
570        return Err(io::Error::new(
571            io::ErrorKind::InvalidData,
572            "invalid player data magic",
573        ));
574    }
575    let version = u16::from_le_bytes([bytes[4], bytes[5]]);
576    if version != expected_version {
577        return Err(io::Error::new(
578            io::ErrorKind::InvalidData,
579            format!("unsupported player data storage version {version}"),
580        ));
581    }
582    zstd::decode_all(&bytes[6..])
583}