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