Skip to main content

steel_core/player/
player_data.rs

1//! Persistent player data structures.
2//!
3//! This module defines the data format for saving and loading player state.
4
5use rustc_hash::FxHashSet;
6use steel_registry::item_stack::ItemStack;
7use steel_registry::stat::Stat;
8use steel_utils::Identifier;
9use steel_utils::types::GameType;
10
11use super::advancement::PlayerAdvancement;
12use super::advancement::progress::AdvancementProgress;
13use super::{
14    Player, PlayerRespawnConfig, abilities::Abilities, experience::Experience, food_data::FoodData,
15    player_inventory::PlayerInventory,
16};
17use crate::player::stats_counter::StatState;
18use crate::{
19    chunk_saver::{ChunkStorage, PersistentEntity},
20    entity::{Entity, EntityFireFreezeState, LivingEntity},
21    inventory::{
22        container::Container,
23        ender_chest::{ENDER_CHEST_SLOTS, PlayerEnderChestContainer},
24    },
25};
26
27/// Current data version for player saves.
28/// Increment when making breaking changes to the format.
29pub const PLAYER_DATA_VERSION: i32 = 7;
30
31/// Persistent player data saved by Steel's storage backend.
32///
33/// This is Steel's runtime save snapshot. Vanilla import/export should live outside
34/// server runtime storage so compatibility logic does not constrain the native format.
35#[derive(Debug, Clone)]
36pub struct PersistentPlayerData {
37    /// Position (x, y, z) in absolute world coordinates.
38    pub pos: [f64; 3],
39
40    /// Velocity (x, y, z) in blocks per tick.
41    pub motion: [f64; 3],
42
43    /// Rotation (yaw, pitch) in degrees.
44    pub rotation: [f32; 2],
45
46    /// Whether the player is on the ground.
47    pub on_ground: bool,
48
49    /// Whether the player is elytra gliding.
50    pub fall_flying: bool,
51
52    /// Remaining fire duration, in ticks.
53    pub remaining_fire_ticks: i32,
54
55    /// Synchronized vanilla `TicksFrozen`.
56    pub ticks_frozen: i32,
57
58    /// Whether the player is currently standing in powder snow.
59    pub is_in_powder_snow: bool,
60
61    /// Whether the player was standing in powder snow last tick.
62    pub was_in_powder_snow: bool,
63
64    /// Whether the player renders as on fire regardless of actual fire ticks.
65    pub has_visual_fire: bool,
66
67    /// Current health points.
68    pub health: f32,
69
70    /// Current game mode (0=survival, 1=creative, 2=adventure, 3=spectator).
71    pub game_mode: i32,
72
73    /// Previous game mode of the player, or `None` if vanilla has not recorded one yet.
74    pub prev_game_mode: Option<i32>,
75
76    /// Player abilities (flight, invulnerability, etc.).
77    pub abilities: PersistentAbilities,
78
79    /// Inventory items with slot indices.
80    pub inventory: Vec<PersistentSlot>,
81
82    /// Currently selected hotbar slot (0-8).
83    pub selected_slot: i32,
84
85    /// Loaded world identifier (e.g., "minecraft:overworld").
86    pub world: String,
87
88    /// Current food level (0–20, default 20).
89    pub food_level: i32,
90
91    /// Food saturation level (0.0–`food_level`, default 5.0).
92    pub food_saturation_level: f32,
93
94    /// Accumulated food exhaustion (0.0–40.0, default 0.0).
95    pub food_exhaustion_level: f32,
96
97    /// Internal tick timer for regen/starvation (default 0).
98    pub food_tick_timer: i32,
99
100    /// Data version for format migrations.
101    pub data_version: i32,
102
103    /// Current experience level
104    pub experience_level: i32,
105
106    /// Vanilla progress toward the next experience level.
107    pub experience_progress: f32,
108
109    /// Vanilla `Player.totalExperience`, updated by point grants but independent of level/progress.
110    pub experience_total: i32,
111
112    /// Saved enchantment seed (`XpSeed`); zero is rerolled when loaded.
113    pub enchantment_seed: i32,
114
115    /// Vanilla death-screen score. Point grants change it with Java `int` wrapping.
116    pub score: i32,
117
118    /// Whether this player has already seen the End credits screen.
119    pub seen_credits: bool,
120
121    /// Vanilla one-player root vehicle tree stored with the player instead of chunk data.
122    pub root_vehicle: Option<PersistentRootVehicle>,
123
124    /// Vanilla per-player respawn configuration set by beds and respawn anchors.
125    pub respawn_config: Option<PlayerRespawnConfig>,
126
127    /// Vanilla in-flight ender pearls stored with the player (`ServerPlayer.enderPearls`).
128    pub ender_pearls: Vec<PersistentEnderPearl>,
129
130    /// Vanilla `EnderChest` items.
131    pub ender_items: Vec<PersistentSlot>,
132
133    /// The tracked statistics of the player with their counters.
134    pub stats: Vec<PersistentStat>,
135
136    /// Persisted advancement progress keyed by advancement identifier.
137    pub advancements: Vec<(Identifier, AdvancementProgress)>,
138}
139
140/// A vanilla `RootVehicle` tree persisted with player data.
141#[derive(Debug, Clone)]
142pub struct PersistentRootVehicle {
143    /// UUID of the direct vehicle the player should reattach to.
144    pub attach: [u8; 16],
145    /// Root vehicle entity tree.
146    pub entity: PersistentEntity,
147}
148
149/// A thrown ender pearl persisted with its owning player.
150///
151/// Mirrors a vanilla `ender_pearls` list entry: the pearl entity plus the world
152/// it lives in (`ender_pearl_dimension`), so it re-spawns in its original world.
153#[derive(Debug, Clone)]
154pub struct PersistentEnderPearl {
155    /// Key of the world the pearl lives in.
156    pub world: String,
157    /// Serialized pearl entity.
158    pub entity: PersistentEntity,
159}
160
161/// Persistent abilities data.
162#[derive(Debug, Clone)]
163pub struct PersistentAbilities {
164    /// Whether the player is invulnerable to damage.
165    pub invulnerable: bool,
166    /// Whether the player is currently flying.
167    pub flying: bool,
168    /// Whether the player is allowed to fly.
169    pub may_fly: bool,
170    /// Whether the player can instantly break blocks (creative mode).
171    pub instabuild: bool,
172    /// Whether the player can place/break blocks.
173    pub may_build: bool,
174    /// Flying speed (default 0.05).
175    pub flying_speed: f32,
176    /// Walking speed (default 0.1).
177    pub walking_speed: f32,
178}
179
180/// An inventory slot with its index.
181#[derive(Debug, Clone)]
182pub struct PersistentSlot {
183    /// Slot index in the inventory.
184    pub slot: i8,
185    /// The item stack in this slot.
186    pub item: ItemStack,
187}
188
189/// Represents a tracked stat with its counter.
190#[derive(Debug, Clone)]
191pub struct PersistentStat {
192    /// The stat being tracked.
193    pub stat: Stat,
194    /// The current count of this stat.
195    pub count: i32,
196}
197
198impl PersistentPlayerData {
199    /// Extracts persistent data from a live player.
200    #[must_use]
201    pub fn from_player(player: &Player) -> Self {
202        let pos = player.position();
203        let (yaw, pitch) = player.rotation();
204        let delta = player.velocity();
205        let on_ground = player.on_ground();
206        let fall_flying = player.is_fall_flying();
207        let fire_freeze = player.fire_freeze_state();
208        let abilities = player.abilities.lock();
209        let inventory = player.inventory.lock();
210        let food_data = player.food_data.lock();
211
212        // Collect non-empty inventory slots
213        let mut slots = Vec::new();
214        // Main inventory (0-35) and equipment (36-42)
215        for slot in 0..PlayerInventory::CONTAINER_SIZE {
216            let item = inventory.get_item(slot);
217            if !item.is_empty() {
218                slots.push(PersistentSlot {
219                    slot: slot as i8,
220                    item: item.clone(),
221                });
222            }
223        }
224
225        let ender_chest_inventory = player.ender_chest_inventory.lock();
226        let mut ender_items = Vec::new();
227        for slot in 0..ENDER_CHEST_SLOTS {
228            let item = ender_chest_inventory.get_item(slot);
229            if !item.is_empty() {
230                ender_items.push(PersistentSlot {
231                    slot: slot as i8,
232                    item: item.clone(),
233                });
234            }
235        }
236
237        let (experience_level, experience_progress, experience_total, enchantment_seed) = {
238            let lock = player.experience.lock();
239            (
240                lock.level(),
241                lock.progress(),
242                lock.total_points(),
243                lock.enchantment_seed(),
244            )
245        };
246        let score = player.score();
247        let root_vehicle = Self::root_vehicle_from_player(player)
248            .or_else(|| player.pending_root_vehicle_for_current_world());
249        let ender_pearls = Self::ender_pearls_from_player(player);
250        let stats = Self::stats_from_player(player);
251        let advancements = player.advancements.lock().persisted_progress();
252
253        Self {
254            pos: [pos.x, pos.y, pos.z],
255            motion: [delta.x, delta.y, delta.z],
256            rotation: [yaw, pitch],
257            on_ground,
258            fall_flying,
259            remaining_fire_ticks: fire_freeze.remaining_fire_ticks(),
260            ticks_frozen: fire_freeze.ticks_frozen(),
261            is_in_powder_snow: fire_freeze.is_in_powder_snow(),
262            was_in_powder_snow: fire_freeze.was_in_powder_snow(),
263            has_visual_fire: fire_freeze.has_visual_fire(),
264            health: player.get_health(),
265            game_mode: player.game_mode() as i32,
266            prev_game_mode: player
267                .previous_game_mode()
268                .map(|game_mode| game_mode as i32),
269            abilities: PersistentAbilities {
270                invulnerable: abilities.invulnerable,
271                flying: abilities.flying,
272                may_fly: abilities.may_fly,
273                instabuild: abilities.instabuild,
274                may_build: abilities.may_build,
275                flying_speed: abilities.flying_speed,
276                walking_speed: abilities.walking_speed,
277            },
278            inventory: slots,
279            selected_slot: i32::from(inventory.get_selected_slot()),
280            world: player.get_world().key.to_string(),
281            food_level: food_data.food_level,
282            food_saturation_level: food_data.saturation_level,
283            food_exhaustion_level: food_data.exhaustion_level,
284            food_tick_timer: food_data.tick_timer,
285            data_version: PLAYER_DATA_VERSION,
286            experience_level,
287            experience_progress,
288            experience_total,
289            enchantment_seed,
290            score,
291            seen_credits: player.has_seen_credits(),
292            root_vehicle,
293            respawn_config: player.respawn_config(),
294
295            ender_pearls,
296            ender_items,
297            stats,
298            advancements,
299        }
300    }
301
302    /// Snapshots the player's live in-flight ender pearls for persistence.
303    fn ender_pearls_from_player(player: &Player) -> Vec<PersistentEnderPearl> {
304        let mut seen = FxHashSet::default();
305        let mut pearls = player
306            .ender_pearls()
307            .iter()
308            .filter_map(|pearl| {
309                let world = pearl.level()?.key.to_string();
310                let entity = ChunkStorage::entity_tree_to_persistent(pearl)?;
311                seen.insert(entity.uuid);
312                Some(PersistentEnderPearl { world, entity })
313            })
314            .collect::<Vec<_>>();
315        pearls.extend(
316            player
317                .pending_ender_pearls()
318                .into_iter()
319                .filter(|pearl| seen.insert(pearl.entity.uuid)),
320        );
321        pearls
322    }
323
324    /// Snapshots the player's tracked stats and their counters for persistence.
325    fn stats_from_player(player: &Player) -> Vec<PersistentStat> {
326        player
327            .stats()
328            .into_iter()
329            .map(|(stat, count)| PersistentStat { stat, count })
330            .collect()
331    }
332
333    fn root_vehicle_from_player(player: &Player) -> Option<PersistentRootVehicle> {
334        let vehicle = player.vehicle()?;
335        let root_vehicle = player.root_vehicle()?;
336        if root_vehicle.id() == player.id() || !root_vehicle.has_exactly_one_player_passenger() {
337            return None;
338        }
339
340        let entity = ChunkStorage::entity_tree_to_persistent(&root_vehicle)?;
341        Some(PersistentRootVehicle {
342            attach: *vehicle.uuid().as_bytes(),
343            entity,
344        })
345    }
346}
347
348impl Player {
349    /// Resets domain-scoped gameplay data to the defaults used for a new player.
350    pub(crate) fn reset_domain_data_for_first_visit(&self) {
351        use glam::DVec3;
352
353        self.set_velocity(DVec3::ZERO);
354        self.set_on_ground(false);
355        self.set_fall_flying(false);
356        self.base()
357            .set_fire_freeze_state(EntityFireFreezeState::new());
358        self.sync_base_fire_freeze_entity_data();
359        self.set_health(self.get_max_health());
360        *self.abilities.lock() = Abilities::default();
361        *self.inventory.lock() = PlayerInventory::new();
362        *self.ender_chest_inventory.lock() = PlayerEnderChestContainer::new();
363        *self.food_data.lock() = FoodData::new();
364        self.stats.lock().reset();
365        *self.advancements.lock() = PlayerAdvancement::default();
366
367        let mut experience = Experience::default();
368        experience.dirty = true;
369        *self.experience.lock() = experience;
370
371        self.set_score(0);
372        self.set_seen_credits(false);
373    }
374}
375
376impl Default for PersistentAbilities {
377    fn default() -> Self {
378        Self {
379            invulnerable: false,
380            flying: false,
381            may_fly: false,
382            instabuild: false,
383            may_build: true,
384            flying_speed: 0.05,
385            walking_speed: 0.1,
386        }
387    }
388}
389
390impl From<&Abilities> for PersistentAbilities {
391    fn from(abilities: &Abilities) -> Self {
392        Self {
393            invulnerable: abilities.invulnerable,
394            flying: abilities.flying,
395            may_fly: abilities.may_fly,
396            instabuild: abilities.instabuild,
397            may_build: abilities.may_build,
398            flying_speed: abilities.flying_speed,
399            walking_speed: abilities.walking_speed,
400        }
401    }
402}
403
404impl From<PersistentAbilities> for Abilities {
405    fn from(persistent: PersistentAbilities) -> Self {
406        Self {
407            invulnerable: persistent.invulnerable,
408            flying: persistent.flying,
409            may_fly: persistent.may_fly,
410            instabuild: persistent.instabuild,
411            may_build: persistent.may_build,
412            flying_speed: persistent.flying_speed,
413            walking_speed: persistent.walking_speed,
414        }
415    }
416}
417
418impl PersistentPlayerData {
419    /// Applies the saved data to a player.
420    ///
421    /// This restores position, rotation, inventory, abilities, etc.
422    pub fn apply_to_player(&self, player: &Player) {
423        self.apply_to_player_inner(player, true);
424    }
425
426    /// Applies saved gameplay state without restoring world-local location data.
427    ///
428    /// Used when the saved world is unavailable or differs from an explicitly
429    /// selected world, which must use the target spawn instead.
430    pub fn apply_to_player_without_location(&self, player: &Player) {
431        self.apply_to_player_inner(player, false);
432    }
433
434    fn apply_to_player_inner(&self, player: &Player, restore_location: bool) {
435        use glam::DVec3;
436
437        if restore_location {
438            // Position
439            player
440                .base()
441                .set_position_local(DVec3::new(self.pos[0], self.pos[1], self.pos[2]));
442
443            // Rotation
444            player.set_rotation((self.rotation[0], self.rotation[1]));
445
446            // Motion/velocity
447            player.set_velocity(DVec3::new(self.motion[0], self.motion[1], self.motion[2]));
448
449            // Ground state
450            player.set_fall_flying(self.fall_flying);
451            player.set_on_ground(self.on_ground);
452        }
453
454        player
455            .base()
456            .set_fire_freeze_state(EntityFireFreezeState::from_parts(
457                self.remaining_fire_ticks,
458                self.ticks_frozen,
459                self.is_in_powder_snow,
460                self.was_in_powder_snow,
461                self.has_visual_fire,
462            ));
463        player.sync_base_fire_freeze_entity_data();
464        player.set_respawn_position(self.respawn_config.clone(), false);
465
466        // Health
467        player.set_health(self.health);
468
469        // Game mode
470        player.restore_game_modes(
471            self.game_mode.into(),
472            self.prev_game_mode.map(GameType::from),
473        );
474
475        // Abilities
476        *player.abilities.lock() = self.abilities.clone().into();
477
478        // Inventory
479        {
480            let mut inventory = player.inventory.lock();
481            // Clear existing inventory first
482            for slot in 0..PlayerInventory::CONTAINER_SIZE {
483                inventory.set_item(slot, ItemStack::empty());
484            }
485            // Restore saved items
486            for slot_data in &self.inventory {
487                let slot_index = slot_data.slot as usize;
488                if slot_index < PlayerInventory::CONTAINER_SIZE {
489                    inventory.set_item(slot_index, slot_data.item.clone());
490                }
491            }
492            // Restore selected slot
493            let selected = self.selected_slot.clamp(0, 8) as u8;
494            inventory.set_selected_slot(selected);
495        }
496
497        {
498            let mut ender_chest_inventory = player.ender_chest_inventory.lock();
499            for slot in 0..ENDER_CHEST_SLOTS {
500                ender_chest_inventory.set_item(slot, ItemStack::empty());
501            }
502            for slot_data in &self.ender_items {
503                let slot_index = slot_data.slot as usize;
504                if slot_index < ENDER_CHEST_SLOTS {
505                    ender_chest_inventory.set_item(slot_index, slot_data.item.clone());
506                }
507            }
508        }
509
510        // Food data
511        {
512            let mut food = player.food_data.lock();
513            food.food_level = self.food_level;
514            food.saturation_level = self.food_saturation_level;
515            food.exhaustion_level = self.food_exhaustion_level;
516            food.tick_timer = self.food_tick_timer;
517        }
518
519        {
520            let mut experience = player.experience.lock();
521            *experience = Experience::from_parts(
522                self.experience_level,
523                self.experience_progress,
524                self.experience_total,
525            )
526            .with_loaded_enchantment_seed(self.enchantment_seed, rand::random);
527        }
528        player.set_score(self.score);
529        player.set_seen_credits(self.seen_credits);
530
531        // Statistics
532        {
533            let mut stats = player.stats.lock();
534
535            // This resets all counters to zero, but also marks them as non-persistent. However, they still need
536            // to be sent to the client the next time they are queried.
537            // This is important so that stale data from the previous domain doesn't stay in the client's cache.
538            // We then populate the counter with all the serialized stat counters from the new domain.
539            stats.reset();
540
541            for PersistentStat { stat, count } in &self.stats {
542                stats.stats.insert(*stat, (*count, StatState::Dirty));
543            }
544        }
545
546        player
547            .advancements
548            .lock()
549            .apply_persisted_progress(self.advancements.iter().cloned());
550    }
551}