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_utils::types::GameType;
8
9use crate::{
10    chunk_saver::{ChunkStorage, PersistentEntity},
11    entity::{Entity, EntityFireFreezeState, LivingEntity},
12    inventory::container::Container,
13};
14
15use super::{
16    Player, abilities::Abilities, experience::Experience, food_data::FoodData,
17    player_inventory::PlayerInventory,
18};
19
20/// Current data version for player saves.
21/// Increment when making breaking changes to the format.
22pub const PLAYER_DATA_VERSION: i32 = 5;
23
24/// Persistent player data saved by Steel's storage backend.
25///
26/// This is Steel's runtime save snapshot. Vanilla import/export should live outside
27/// server runtime storage so compatibility logic does not constrain the native format.
28#[derive(Debug, Clone)]
29pub struct PersistentPlayerData {
30    /// Position (x, y, z) in absolute world coordinates.
31    pub pos: [f64; 3],
32
33    /// Velocity (x, y, z) in blocks per tick.
34    pub motion: [f64; 3],
35
36    /// Rotation (yaw, pitch) in degrees.
37    pub rotation: [f32; 2],
38
39    /// Whether the player is on the ground.
40    pub on_ground: bool,
41
42    /// Whether the player is elytra gliding.
43    pub fall_flying: bool,
44
45    /// Vanilla `remainingFireTicks`.
46    pub remaining_fire_ticks: i32,
47
48    /// Synchronized vanilla `TicksFrozen`.
49    pub ticks_frozen: i32,
50
51    /// Vanilla `isInPowderSnow`.
52    pub is_in_powder_snow: bool,
53
54    /// Vanilla `wasInPowderSnow`.
55    pub was_in_powder_snow: bool,
56
57    /// Vanilla `hasVisualFire`.
58    pub has_visual_fire: bool,
59
60    /// Current health points.
61    pub health: f32,
62
63    /// Current game mode (0=survival, 1=creative, 2=adventure, 3=spectator).
64    pub game_mode: i32,
65
66    /// Previous game mode of the player, or `None` if vanilla has not recorded one yet.
67    pub prev_game_mode: Option<i32>,
68
69    /// Player abilities (flight, invulnerability, etc.).
70    pub abilities: PersistentAbilities,
71
72    /// Inventory items with slot indices.
73    pub inventory: Vec<PersistentSlot>,
74
75    /// Currently selected hotbar slot (0-8).
76    pub selected_slot: i32,
77
78    /// Loaded world identifier (e.g., "minecraft:overworld").
79    pub world: String,
80
81    /// Current food level (0–20, default 20).
82    pub food_level: i32,
83
84    /// Food saturation level (0.0–`food_level`, default 5.0).
85    pub food_saturation_level: f32,
86
87    /// Accumulated food exhaustion (0.0–40.0, default 0.0).
88    pub food_exhaustion_level: f32,
89
90    /// Internal tick timer for regen/starvation (default 0).
91    pub food_tick_timer: i32,
92
93    /// Data version for format migrations.
94    pub data_version: i32,
95
96    /// Current experience level
97    pub experience_level: i32,
98
99    /// Vanilla progress toward the next experience level.
100    pub experience_progress: f32,
101
102    /// Vanilla `Player.totalExperience`, updated by point grants but independent of level/progress.
103    pub experience_total: i32,
104
105    /// Vanilla death-screen score. Point grants change it with Java `int` wrapping.
106    pub score: i32,
107
108    /// Vanilla `ServerPlayer.seenCredits`.
109    pub seen_credits: bool,
110
111    /// Vanilla one-player root vehicle tree stored with the player instead of chunk data.
112    pub root_vehicle: Option<PersistentRootVehicle>,
113
114    /// Vanilla in-flight ender pearls stored with the player (`ServerPlayer.enderPearls`).
115    pub ender_pearls: Vec<PersistentEnderPearl>,
116}
117
118/// A vanilla `RootVehicle` tree persisted with player data.
119#[derive(Debug, Clone)]
120pub struct PersistentRootVehicle {
121    /// UUID of the direct vehicle the player should reattach to.
122    pub attach: [u8; 16],
123    /// Root vehicle entity tree.
124    pub entity: PersistentEntity,
125}
126
127/// A thrown ender pearl persisted with its owning player.
128///
129/// Mirrors a vanilla `ender_pearls` list entry: the pearl entity plus the world
130/// it lives in (`ender_pearl_dimension`), so it re-spawns in its original world.
131#[derive(Debug, Clone)]
132pub struct PersistentEnderPearl {
133    /// Key of the world the pearl lives in.
134    pub world: String,
135    /// Serialized pearl entity.
136    pub entity: PersistentEntity,
137}
138
139/// Persistent abilities data.
140#[derive(Debug, Clone)]
141pub struct PersistentAbilities {
142    /// Whether the player is invulnerable to damage.
143    pub invulnerable: bool,
144    /// Whether the player is currently flying.
145    pub flying: bool,
146    /// Whether the player is allowed to fly.
147    pub may_fly: bool,
148    /// Whether the player can instantly break blocks (creative mode).
149    pub instabuild: bool,
150    /// Whether the player can place/break blocks.
151    pub may_build: bool,
152    /// Flying speed (default 0.05).
153    pub flying_speed: f32,
154    /// Walking speed (default 0.1).
155    pub walking_speed: f32,
156}
157
158/// An inventory slot with its index.
159#[derive(Debug, Clone)]
160pub struct PersistentSlot {
161    /// Slot index in the inventory.
162    pub slot: i8,
163    /// The item stack in this slot.
164    pub item: ItemStack,
165}
166
167impl PersistentPlayerData {
168    /// Extracts persistent data from a live player.
169    #[must_use]
170    pub fn from_player(player: &Player) -> Self {
171        let pos = player.position();
172        let (yaw, pitch) = player.rotation();
173        let delta = player.velocity();
174        let on_ground = player.on_ground();
175        let fall_flying = player.is_fall_flying();
176        let fire_freeze = player.fire_freeze_state();
177        let abilities = player.abilities.lock();
178        let inventory = player.inventory.lock();
179        let food_data = player.food_data.lock();
180
181        // Collect non-empty inventory slots
182        let mut slots = Vec::new();
183        // Main inventory (0-35) and equipment (36-42)
184        for slot in 0..PlayerInventory::CONTAINER_SIZE {
185            let item = inventory.get_item(slot);
186            if !item.is_empty() {
187                slots.push(PersistentSlot {
188                    slot: slot as i8,
189                    item: item.clone(),
190                });
191            }
192        }
193
194        let (experience_level, experience_progress, experience_total) = {
195            let lock = player.experience.lock();
196            (lock.level(), lock.progress(), lock.total_points())
197        };
198        let score = player.score();
199        let root_vehicle = Self::root_vehicle_from_player(player)
200            .or_else(|| player.pending_root_vehicle_for_current_world());
201        let ender_pearls = Self::ender_pearls_from_player(player);
202
203        Self {
204            pos: [pos.x, pos.y, pos.z],
205            motion: [delta.x, delta.y, delta.z],
206            rotation: [yaw, pitch],
207            on_ground,
208            fall_flying,
209            remaining_fire_ticks: fire_freeze.remaining_fire_ticks(),
210            ticks_frozen: fire_freeze.ticks_frozen(),
211            is_in_powder_snow: fire_freeze.is_in_powder_snow(),
212            was_in_powder_snow: fire_freeze.was_in_powder_snow(),
213            has_visual_fire: fire_freeze.has_visual_fire(),
214            health: player.get_health(),
215            game_mode: player.game_mode() as i32,
216            prev_game_mode: player
217                .previous_game_mode()
218                .map(|game_mode| game_mode as i32),
219            abilities: PersistentAbilities {
220                invulnerable: abilities.invulnerable,
221                flying: abilities.flying,
222                may_fly: abilities.may_fly,
223                instabuild: abilities.instabuild,
224                may_build: abilities.may_build,
225                flying_speed: abilities.flying_speed,
226                walking_speed: abilities.walking_speed,
227            },
228            inventory: slots,
229            selected_slot: i32::from(inventory.get_selected_slot()),
230            world: player.get_world().key.to_string(),
231            food_level: food_data.food_level,
232            food_saturation_level: food_data.saturation_level,
233            food_exhaustion_level: food_data.exhaustion_level,
234            food_tick_timer: food_data.tick_timer,
235            data_version: PLAYER_DATA_VERSION,
236            experience_level,
237            experience_progress,
238            experience_total,
239            score,
240            seen_credits: player.has_seen_credits(),
241            root_vehicle,
242            ender_pearls,
243        }
244    }
245
246    /// Snapshots the player's live in-flight ender pearls for persistence.
247    fn ender_pearls_from_player(player: &Player) -> Vec<PersistentEnderPearl> {
248        let mut seen = FxHashSet::default();
249        let mut pearls = player
250            .ender_pearls()
251            .iter()
252            .filter_map(|pearl| {
253                let world = pearl.level()?.key.to_string();
254                let entity = ChunkStorage::entity_tree_to_persistent(pearl)?;
255                seen.insert(entity.uuid);
256                Some(PersistentEnderPearl { world, entity })
257            })
258            .collect::<Vec<_>>();
259        pearls.extend(
260            player
261                .pending_ender_pearls()
262                .into_iter()
263                .filter(|pearl| seen.insert(pearl.entity.uuid)),
264        );
265        pearls
266    }
267
268    fn root_vehicle_from_player(player: &Player) -> Option<PersistentRootVehicle> {
269        let vehicle = player.vehicle()?;
270        let root_vehicle = player.root_vehicle()?;
271        if root_vehicle.id() == player.id() || !root_vehicle.has_exactly_one_player_passenger() {
272            return None;
273        }
274
275        let entity = ChunkStorage::entity_tree_to_persistent(&root_vehicle)?;
276        Some(PersistentRootVehicle {
277            attach: *vehicle.uuid().as_bytes(),
278            entity,
279        })
280    }
281}
282
283impl Player {
284    /// Resets domain-scoped gameplay data to the defaults used for a new player.
285    pub(crate) fn reset_domain_data_for_first_visit(&self) {
286        use glam::DVec3;
287
288        self.set_velocity(DVec3::ZERO);
289        self.set_on_ground(false);
290        self.set_fall_flying(false);
291        self.base()
292            .set_fire_freeze_state(EntityFireFreezeState::new());
293        self.sync_base_fire_freeze_entity_data();
294        self.set_health(self.get_max_health());
295        *self.abilities.lock() = Abilities::default();
296        *self.inventory.lock() = PlayerInventory::new();
297        *self.food_data.lock() = FoodData::new();
298
299        let mut experience = Experience::default();
300        experience.dirty = true;
301        *self.experience.lock() = experience;
302
303        self.set_score(0);
304        self.set_seen_credits(false);
305    }
306}
307
308impl Default for PersistentAbilities {
309    fn default() -> Self {
310        Self {
311            invulnerable: false,
312            flying: false,
313            may_fly: false,
314            instabuild: false,
315            may_build: true,
316            flying_speed: 0.05,
317            walking_speed: 0.1,
318        }
319    }
320}
321
322impl From<&Abilities> for PersistentAbilities {
323    fn from(abilities: &Abilities) -> Self {
324        Self {
325            invulnerable: abilities.invulnerable,
326            flying: abilities.flying,
327            may_fly: abilities.may_fly,
328            instabuild: abilities.instabuild,
329            may_build: abilities.may_build,
330            flying_speed: abilities.flying_speed,
331            walking_speed: abilities.walking_speed,
332        }
333    }
334}
335
336impl From<PersistentAbilities> for Abilities {
337    fn from(persistent: PersistentAbilities) -> Self {
338        Self {
339            invulnerable: persistent.invulnerable,
340            flying: persistent.flying,
341            may_fly: persistent.may_fly,
342            instabuild: persistent.instabuild,
343            may_build: persistent.may_build,
344            flying_speed: persistent.flying_speed,
345            walking_speed: persistent.walking_speed,
346        }
347    }
348}
349
350impl PersistentPlayerData {
351    /// Applies the saved data to a player.
352    ///
353    /// This restores position, rotation, inventory, abilities, etc.
354    pub fn apply_to_player(&self, player: &Player) {
355        self.apply_to_player_inner(player, true);
356    }
357
358    /// Applies saved gameplay state without restoring world-local location data.
359    ///
360    /// Used when the saved world is unavailable or differs from an explicitly
361    /// selected world, which must use the target spawn instead.
362    pub fn apply_to_player_without_location(&self, player: &Player) {
363        self.apply_to_player_inner(player, false);
364    }
365
366    fn apply_to_player_inner(&self, player: &Player, restore_location: bool) {
367        use glam::DVec3;
368
369        if restore_location {
370            // Position
371            player
372                .base()
373                .set_position_local(DVec3::new(self.pos[0], self.pos[1], self.pos[2]));
374
375            // Rotation
376            player.set_rotation((self.rotation[0], self.rotation[1]));
377
378            // Motion/velocity
379            player.set_velocity(DVec3::new(self.motion[0], self.motion[1], self.motion[2]));
380
381            // Ground state
382            player.set_fall_flying(self.fall_flying);
383            player.set_on_ground(self.on_ground);
384        }
385
386        player
387            .base()
388            .set_fire_freeze_state(EntityFireFreezeState::from_parts(
389                self.remaining_fire_ticks,
390                self.ticks_frozen,
391                self.is_in_powder_snow,
392                self.was_in_powder_snow,
393                self.has_visual_fire,
394            ));
395        player.sync_base_fire_freeze_entity_data();
396
397        // Health
398        player.set_health(self.health);
399
400        // Game mode
401        player.restore_game_modes(
402            self.game_mode.into(),
403            self.prev_game_mode.map(GameType::from),
404        );
405
406        // Abilities
407        *player.abilities.lock() = self.abilities.clone().into();
408
409        // Inventory
410        {
411            let mut inventory = player.inventory.lock();
412            // Clear existing inventory first
413            for slot in 0..PlayerInventory::CONTAINER_SIZE {
414                inventory.set_item(slot, ItemStack::empty());
415            }
416            // Restore saved items
417            for slot_data in &self.inventory {
418                let slot_index = slot_data.slot as usize;
419                if slot_index < PlayerInventory::CONTAINER_SIZE {
420                    inventory.set_item(slot_index, slot_data.item.clone());
421                }
422            }
423            // Restore selected slot
424            let selected = self.selected_slot.clamp(0, 8) as u8;
425            inventory.set_selected_slot(selected);
426        }
427
428        // Food data
429        {
430            let mut food = player.food_data.lock();
431            food.food_level = self.food_level;
432            food.saturation_level = self.food_saturation_level;
433            food.exhaustion_level = self.food_exhaustion_level;
434            food.tick_timer = self.food_tick_timer;
435        }
436
437        {
438            let mut experience = player.experience.lock();
439            *experience = Experience::from_parts(
440                self.experience_level,
441                self.experience_progress,
442                self.experience_total,
443            );
444        }
445        player.set_score(self.score);
446        player.set_seen_credits(self.seen_credits);
447    }
448}