Skip to main content

steel_core/level_data/
mod.rs

1//! Level data persistence module.
2//!
3//! This module handles saving and loading world-level data like game rules,
4//! time, weather, spawn point, and seed. This data is stored in `level.toml`
5//! in each world's directory.
6
7use std::{
8    io,
9    path::{Path, PathBuf},
10};
11
12use rustc_hash::FxHashMap;
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14use steel_registry::REGISTRY;
15use steel_registry::game_rules::GameRuleValues;
16use steel_utils::types::Difficulty;
17use steel_utils::{BlockPos, GlobalPos, Identifier};
18use tokio::fs;
19
20use crate::world::clock::WorldClockManager;
21
22/// Persistent world border data stored with Steel level data.
23#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
24#[serde(default)]
25pub struct WorldBorderData {
26    /// Border center X coordinate.
27    pub center_x: f64,
28    /// Border center Z coordinate.
29    pub center_z: f64,
30    /// Damage dealt per block outside the safe zone.
31    pub damage_per_block: f64,
32    /// Distance outside the border before damage starts.
33    pub safe_zone: f64,
34    /// Client warning distance in blocks.
35    pub warning_blocks: i32,
36    /// Client warning time in seconds.
37    pub warning_time: i32,
38    /// Current border size.
39    pub size: f64,
40    /// Remaining lerp time in ticks.
41    pub lerp_time: i64,
42    /// Target size for a moving border.
43    pub lerp_target: f64,
44}
45
46impl Default for WorldBorderData {
47    fn default() -> Self {
48        Self {
49            center_x: 0.0,
50            center_z: 0.0,
51            damage_per_block: 0.2,
52            safe_zone: 5.0,
53            warning_blocks: 5,
54            warning_time: 300,
55            size: f64::from(5.999_997E7_f32),
56            lerp_time: 0,
57            lerp_target: 0.0,
58        }
59    }
60}
61
62/// Persistent level data that gets saved to disk.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct LevelData {
65    /// World seed for terrain generation.
66    pub seed: i64,
67    /// Total game time in ticks.
68    pub game_time: i64,
69    /// Independently advancing world-clock instances for this loaded world.
70    #[serde(default)]
71    pub(crate) world_clocks: WorldClockManager,
72    /// World spawn point.
73    pub spawn: SpawnPoint,
74    /// Vanilla global respawn data for this domain, stored on the domain default world.
75    #[serde(default)]
76    pub respawn: Option<RespawnData>,
77    /// Weather state.
78    pub weather: WeatherState,
79    /// Persistent world border state.
80    #[serde(default)]
81    pub world_border: WorldBorderData,
82    /// World difficulty.
83    #[serde(default)]
84    pub difficulty: Difficulty,
85    /// Whether the difficulty is locked.
86    #[serde(default)]
87    pub difficulty_locked: bool,
88    /// Game rules (stored as name -> value pairs for serialization).
89    pub game_rules: FxHashMap<String, serde_json::Value>,
90    /// Runtime game rule values (not serialized, loaded from `game_rules`).
91    #[serde(skip)]
92    pub game_rules_values: GameRuleValues,
93    /// Whether the world has been initialized.
94    pub initialized: bool,
95    /// Generator settings this persisted world was created with.
96    #[serde(default)]
97    pub generation: Option<WorldGenerationSettings>,
98}
99
100/// Persisted generator metadata used to reject incompatible config changes.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct WorldGenerationSettings {
103    /// Generator factory identifier.
104    pub generator: Identifier,
105    /// Generator config after applying generator defaults.
106    pub config: toml::Value,
107    /// Dimension type used by the generator output.
108    pub dimension_type: Identifier,
109    /// Minimum build Y for the dimension type.
110    pub min_y: i32,
111    /// Total build height for the dimension type.
112    pub height: i32,
113}
114
115#[derive(Deserialize)]
116struct SavedLevelSeed {
117    seed: i64,
118}
119
120impl WorldGenerationSettings {
121    /// Builds persisted generator metadata from the resolved startup config.
122    #[must_use]
123    pub fn from_generator_config(
124        generator: Identifier,
125        config: &toml::Value,
126        dimension_type: Identifier,
127        min_y: i32,
128        height: i32,
129    ) -> Self {
130        Self {
131            generator,
132            config: config.clone(),
133            dimension_type,
134            min_y,
135            height,
136        }
137    }
138}
139
140fn describe_generation_settings(settings: &WorldGenerationSettings) -> String {
141    format!(
142        "generator {}, dimension_type {}, min_y {}, height {}, config {}",
143        settings.generator,
144        settings.dimension_type,
145        settings.min_y,
146        settings.height,
147        generation_config_string(&settings.config),
148    )
149}
150
151fn generation_config_string(config: &toml::Value) -> String {
152    match toml::to_string(config) {
153        Ok(value) => value.trim().to_owned(),
154        Err(_) => "<invalid generator config>".to_owned(),
155    }
156}
157
158/// Spawn point data.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct SpawnPoint {
161    /// X coordinate.
162    pub x: i32,
163    /// Y coordinate.
164    pub y: i32,
165    /// Z coordinate.
166    pub z: i32,
167    /// Spawn angle (yaw).
168    pub angle: f32,
169}
170
171impl Default for SpawnPoint {
172    fn default() -> Self {
173        Self {
174            x: 0,
175            y: 64,
176            z: 0,
177            angle: 0.0,
178        }
179    }
180}
181
182/// Vanilla default respawn data.
183#[derive(Debug, Clone, PartialEq)]
184pub struct RespawnData {
185    /// Dimension and block position of the default respawn.
186    pub global_pos: GlobalPos,
187    /// Spawn yaw, wrapped with vanilla `Mth.wrapDegrees`.
188    pub yaw: f32,
189    /// Spawn pitch, clamped to vanilla's player pitch range.
190    pub pitch: f32,
191}
192
193impl RespawnData {
194    /// Creates respawn data for the given global position.
195    #[must_use]
196    pub fn new(global_pos: GlobalPos, yaw: f32, pitch: f32) -> Self {
197        Self {
198            global_pos,
199            yaw: wrap_degrees(yaw),
200            pitch: pitch.clamp(-90.0, 90.0),
201        }
202    }
203
204    /// Creates respawn data for a dimension and block position.
205    #[must_use]
206    pub fn of(dimension: Identifier, pos: BlockPos, yaw: f32, pitch: f32) -> Self {
207        Self::new(GlobalPos::new(dimension, pos), yaw, pitch)
208    }
209
210    /// Returns the respawn dimension.
211    #[must_use]
212    pub const fn dimension(&self) -> &Identifier {
213        &self.global_pos.dimension
214    }
215
216    /// Returns the respawn block position.
217    #[must_use]
218    pub const fn pos(&self) -> BlockPos {
219        self.global_pos.pos
220    }
221}
222
223fn wrap_degrees(mut degrees: f32) -> f32 {
224    degrees %= 360.0;
225    if degrees >= 180.0 {
226        degrees -= 360.0;
227    }
228    if degrees < -180.0 {
229        degrees += 360.0;
230    }
231    degrees
232}
233
234#[derive(Serialize, Deserialize)]
235struct SerializedRespawnData {
236    dimension: Identifier,
237    x: i32,
238    y: i32,
239    z: i32,
240    yaw: f32,
241    pitch: f32,
242}
243
244impl Serialize for RespawnData {
245    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
246    where
247        S: Serializer,
248    {
249        SerializedRespawnData {
250            dimension: self.global_pos.dimension.clone(),
251            x: self.global_pos.pos.x(),
252            y: self.global_pos.pos.y(),
253            z: self.global_pos.pos.z(),
254            yaw: self.yaw,
255            pitch: self.pitch,
256        }
257        .serialize(serializer)
258    }
259}
260
261impl<'de> Deserialize<'de> for RespawnData {
262    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
263    where
264        D: Deserializer<'de>,
265    {
266        let data = SerializedRespawnData::deserialize(deserializer)?;
267        Ok(Self::of(
268            data.dimension,
269            BlockPos::new(data.x, data.y, data.z),
270            data.yaw,
271            data.pitch,
272        ))
273    }
274}
275
276/// Weather state.
277#[derive(Debug, Clone, Serialize, Deserialize, Default)]
278pub struct WeatherState {
279    /// Whether it is currently raining.
280    pub raining: bool,
281    /// Ticks until rain state changes.
282    pub rain_time: i32,
283    /// Whether it is currently thundering.
284    pub thundering: bool,
285    /// Ticks until thunder state changes.
286    pub thunder_time: i32,
287    /// Ticks of clear weather remaining.
288    pub clear_weather_time: i32,
289}
290
291impl Default for LevelData {
292    fn default() -> Self {
293        Self::new_with_seed(rand::random())
294    }
295}
296
297impl LevelData {
298    /// Creates new level data with the given seed.
299    #[must_use]
300    pub fn new_with_seed(seed: i64) -> Self {
301        Self::new_with_seed_and_difficulty(seed, Difficulty::default())
302    }
303
304    /// Creates new level data with the given seed and difficulty.
305    #[must_use]
306    pub fn new_with_seed_and_difficulty(seed: i64, difficulty: Difficulty) -> Self {
307        Self {
308            seed,
309            game_time: 0,
310            world_clocks: WorldClockManager::new(),
311            spawn: SpawnPoint::default(),
312            respawn: None,
313            weather: WeatherState::default(),
314            world_border: WorldBorderData::default(),
315            difficulty,
316            difficulty_locked: false,
317            game_rules: FxHashMap::default(),
318            game_rules_values: GameRuleValues::new(&REGISTRY.game_rules),
319            initialized: false,
320            generation: None,
321        }
322    }
323
324    /// Verifies saved generator metadata against the current config.
325    ///
326    /// Returns whether missing metadata was adopted and should be saved.
327    pub fn validate_generation_settings(
328        &mut self,
329        expected: WorldGenerationSettings,
330    ) -> io::Result<bool> {
331        match self.generation.as_ref() {
332            Some(saved) if saved == &expected => Ok(false),
333            Some(saved) => Err(io::Error::new(
334                io::ErrorKind::InvalidData,
335                format!(
336                    "world generation settings do not match saved level data: saved {}; configured {}. Delete or regenerate this world's saved chunks, or restore the previous generator config.",
337                    describe_generation_settings(saved),
338                    describe_generation_settings(&expected),
339                ),
340            )),
341            None => {
342                self.generation = Some(expected);
343                Ok(true)
344            }
345        }
346    }
347
348    /// Loads game rules from the serialized map into the runtime values.
349    pub fn load_game_rules(&mut self) {
350        self.game_rules_values = GameRuleValues::new(&REGISTRY.game_rules);
351        for (name, value) in &self.game_rules {
352            self.game_rules_values
353                .set_serialized_by_name(name, value, &REGISTRY.game_rules);
354        }
355    }
356
357    /// Saves game rules from the runtime values to the serialized map.
358    pub fn save_game_rules(&mut self) {
359        self.game_rules.clear();
360        for (_, rule) in REGISTRY.game_rules.iter() {
361            let name = if rule.key().namespace == Identifier::VANILLA_NAMESPACE {
362                rule.key().path.to_string()
363            } else {
364                rule.key().to_string()
365            };
366            let value = rule.serialize_erased_value(
367                self.game_rules_values
368                    .get_erased(rule, &REGISTRY.game_rules),
369            );
370            self.game_rules.insert(name, value);
371        }
372    }
373
374    /// Gets the spawn position as a `BlockPos`.
375    #[must_use]
376    pub const fn spawn_pos(&self) -> BlockPos {
377        BlockPos::new(self.spawn.x, self.spawn.y, self.spawn.z)
378    }
379
380    /// Sets the spawn position from a `BlockPos`.
381    pub const fn set_spawn_pos(&mut self, pos: BlockPos) {
382        self.spawn.x = pos.x();
383        self.spawn.y = pos.y();
384        self.spawn.z = pos.z();
385    }
386
387    /// Returns saved respawn data, or the legacy local spawn as a compatibility default.
388    #[must_use]
389    pub fn respawn_data_or_local(&self, dimension: &Identifier) -> RespawnData {
390        self.respawn.clone().unwrap_or_else(|| {
391            RespawnData::of(dimension.clone(), self.spawn_pos(), self.spawn.angle, 0.0)
392        })
393    }
394
395    /// Sets the saved respawn data.
396    pub fn set_respawn_data(&mut self, respawn_data: RespawnData) {
397        self.respawn = Some(respawn_data);
398    }
399}
400
401/// Manages level data persistence for a world.
402pub struct LevelDataManager {
403    /// Path to the level.toml file.
404    path: Option<PathBuf>,
405    /// Cached level data.
406    data: LevelData,
407    /// Whether data has been modified since last save.
408    dirty: bool,
409}
410
411impl LevelDataManager {
412    /// Creates a new level data manager for the given world directory.
413    ///
414    /// If `level.toml` exists, it will be loaded (the provided seed is ignored).
415    /// Otherwise, new data will be created with the provided seed.
416    pub async fn new(
417        world_dir: Option<impl AsRef<Path>>,
418        seed: i64,
419        difficulty: Difficulty,
420        generation: WorldGenerationSettings,
421    ) -> io::Result<Self> {
422        let (data, path, dirty) = if let Some(dir) = &world_dir {
423            let path = dir.as_ref().join("level.toml");
424
425            let (data, dirty) = if path.exists() {
426                // Load existing level data (seed from file takes precedence)
427                let content = fs::read_to_string(&path).await?;
428                let mut loaded: LevelData = toml::from_str(&content).map_err(|e| {
429                    io::Error::new(
430                        io::ErrorKind::InvalidData,
431                        format!("Invalid level.toml: {e}"),
432                    )
433                })?;
434                // Initialize runtime game rules from serialized values
435                loaded.load_game_rules();
436                let initialized_clocks = loaded
437                    .world_clocks
438                    .initialize_registered_clocks()
439                    .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
440                let adopted_generation = loaded.validate_generation_settings(generation)?;
441                (loaded, adopted_generation || initialized_clocks)
442            } else {
443                // Create new level data with the provided defaults.
444                let mut data = LevelData::new_with_seed_and_difficulty(seed, difficulty);
445                data.generation = Some(generation);
446                (data, true)
447            };
448            (data, Some(path), dirty)
449        } else {
450            let mut data = LevelData::new_with_seed_and_difficulty(seed, difficulty);
451            data.generation = Some(generation);
452            (data, None, false)
453        };
454
455        Ok(Self { path, data, dirty })
456    }
457
458    /// Loads the saved world seed from `level.toml`, or returns the provided default.
459    pub async fn load_seed_or_default(
460        world_dir: Option<impl AsRef<Path>>,
461        default_seed: i64,
462    ) -> io::Result<i64> {
463        let Some(dir) = world_dir else {
464            return Ok(default_seed);
465        };
466
467        let path = dir.as_ref().join("level.toml");
468        if !path.exists() {
469            return Ok(default_seed);
470        }
471
472        let content = fs::read_to_string(path).await?;
473        let saved: SavedLevelSeed = toml::from_str(&content).map_err(|e| {
474            io::Error::new(
475                io::ErrorKind::InvalidData,
476                format!("Invalid level.toml: {e}"),
477            )
478        })?;
479        Ok(saved.seed)
480    }
481
482    /// Gets a reference to the level data.
483    #[must_use]
484    pub const fn data(&self) -> &LevelData {
485        &self.data
486    }
487
488    /// Gets a mutable reference to the level data and marks it as dirty.
489    pub const fn data_mut(&mut self) -> &mut LevelData {
490        self.dirty = true;
491        &mut self.data
492    }
493
494    /// Returns whether the data has been modified since last save.
495    #[must_use]
496    pub const fn is_dirty(&self) -> bool {
497        self.dirty
498    }
499
500    /// Marks the data as dirty (needs saving).
501    pub const fn mark_dirty(&mut self) {
502        self.dirty = true;
503    }
504
505    /// Saves the level data to disk if it has been modified.
506    pub async fn save(&mut self) -> io::Result<()> {
507        if !self.dirty {
508            return Ok(());
509        }
510
511        let Some(world_path) = &self.path else {
512            self.dirty = false;
513            return Ok(());
514        };
515        if let Some(parent) = world_path.parent() {
516            fs::create_dir_all(parent).await?;
517        }
518
519        // Export runtime game rules to serializable format before saving
520        self.data.save_game_rules();
521
522        let content = toml::to_string_pretty(&self.data)
523            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
524        fs::write(world_path, content).await?;
525        self.dirty = false;
526
527        log::debug!("Saved level data to {}", world_path.display());
528        Ok(())
529    }
530
531    /// Gets the seed.
532    #[must_use]
533    pub const fn seed(&self) -> i64 {
534        self.data.seed
535    }
536
537    /// Gets the game time.
538    #[must_use]
539    pub const fn game_time(&self) -> i64 {
540        self.data.game_time
541    }
542
543    /// Sets the game time.
544    pub const fn set_game_time(&mut self, time: i64) {
545        self.data.game_time = time;
546        self.dirty = true;
547    }
548
549    /// Returns this world's clock manager.
550    #[must_use]
551    pub(crate) const fn world_clocks(&self) -> &WorldClockManager {
552        &self.data.world_clocks
553    }
554
555    /// Returns this world's mutable clock manager and marks level data dirty.
556    pub(crate) const fn world_clocks_mut(&mut self) -> &mut WorldClockManager {
557        self.dirty = true;
558        &mut self.data.world_clocks
559    }
560
561    /// Gets the clear weather time
562    #[must_use]
563    pub const fn clear_weather_time(&self) -> i32 {
564        self.data.weather.clear_weather_time
565    }
566
567    /// Sets the clear weather time
568    pub const fn set_clear_weather_time(&mut self, time: i32) {
569        self.data.weather.clear_weather_time = time;
570        self.dirty = true;
571    }
572
573    /// Gets the rain time
574    #[must_use]
575    pub const fn rain_time(&self) -> i32 {
576        self.data.weather.rain_time
577    }
578
579    /// Sets the rain time
580    pub const fn set_rain_time(&mut self, time: i32) {
581        self.data.weather.rain_time = time;
582        self.dirty = true;
583    }
584
585    /// Gets the thunder time
586    #[must_use]
587    pub const fn thunder_time(&self) -> i32 {
588        self.data.weather.thunder_time
589    }
590
591    /// Sets the thunder time
592    pub const fn set_thunder_time(&mut self, time: i32) {
593        self.data.weather.thunder_time = time;
594        self.dirty = true;
595    }
596
597    /// Checks if it's raining
598    #[must_use]
599    pub const fn is_raining(&self) -> bool {
600        self.data.weather.raining
601    }
602
603    /// Sets whether it's raining
604    pub const fn set_raining(&mut self, raining: bool) {
605        self.data.weather.raining = raining;
606        self.dirty = true;
607    }
608
609    /// Checks if it's thundering
610    #[must_use]
611    pub const fn is_thundering(&self) -> bool {
612        self.data.weather.thundering
613    }
614
615    /// Sets whether it's thundering
616    pub const fn set_thundering(&mut self, thundering: bool) {
617        self.data.weather.thundering = thundering;
618        self.dirty = true;
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use std::{
626        env, fs as std_fs,
627        path::PathBuf,
628        process,
629        time::{SystemTime, UNIX_EPOCH},
630    };
631    use steel_registry::{
632        init_vanilla_registry,
633        vanilla_game_rules::{KEEP_INVENTORY, RANDOM_TICK_SPEED},
634        vanilla_world_clocks,
635    };
636    use toml::map::Map;
637
638    fn settings(dimension_type: &str, height: i32) -> WorldGenerationSettings {
639        let mut config = Map::new();
640        config.insert(
641            "dimension_type".to_owned(),
642            toml::Value::String(dimension_type.to_owned()),
643        );
644        WorldGenerationSettings {
645            generator: Identifier::vanilla_static("flat"),
646            config: toml::Value::Table(config),
647            dimension_type: dimension_type
648                .parse()
649                .expect("valid dimension type identifier"),
650            min_y: 0,
651            height,
652        }
653    }
654
655    fn temp_level_data_dir(test_name: &str) -> PathBuf {
656        let unique = SystemTime::now()
657            .duration_since(UNIX_EPOCH)
658            .expect("system clock should be after unix epoch")
659            .as_nanos();
660        let path = env::temp_dir().join(format!(
661            "steel-level-data-{test_name}-{}-{unique}",
662            process::id()
663        ));
664        std_fs::create_dir_all(&path).expect("temp level data dir should be created");
665        path
666    }
667
668    #[tokio::test]
669    async fn load_seed_prefers_saved_level_toml() {
670        let dir = temp_level_data_dir("saved-seed");
671        std_fs::write(dir.join("level.toml"), "seed = 42\n").expect("level.toml should be written");
672
673        let seed = LevelDataManager::load_seed_or_default(Some(dir.as_path()), 7)
674            .await
675            .expect("saved level seed should load");
676        let _ = std_fs::remove_dir_all(&dir);
677
678        assert_eq!(seed, 42);
679    }
680
681    #[tokio::test]
682    async fn load_seed_returns_default_when_level_toml_is_missing() {
683        let dir = temp_level_data_dir("missing-seed");
684
685        let seed = LevelDataManager::load_seed_or_default(Some(dir.as_path()), 7)
686            .await
687            .expect("missing level.toml should use default seed");
688        let _ = std_fs::remove_dir_all(&dir);
689
690        assert_eq!(seed, 7);
691    }
692
693    #[test]
694    fn adopts_missing_generation_settings() {
695        init_vanilla_registry();
696        let mut data = LevelData::new_with_seed(1);
697
698        let adopted = data
699            .validate_generation_settings(settings("minecraft:overworld", 384))
700            .expect("missing settings should be adopted");
701
702        assert!(adopted);
703        assert!(data.generation.is_some());
704    }
705
706    #[test]
707    fn rejects_mismatched_generation_settings() {
708        init_vanilla_registry();
709        let mut data = LevelData::new_with_seed(1);
710        data.generation = Some(settings("minecraft:the_nether", 128));
711
712        let error = data
713            .validate_generation_settings(settings("minecraft:overworld", 384))
714            .expect_err("mismatched settings should be rejected");
715
716        let message = error.to_string();
717        assert!(message.contains("world generation settings do not match"));
718        assert!(message.contains("minecraft:the_nether"));
719        assert!(message.contains("minecraft:overworld"));
720    }
721
722    #[test]
723    fn respawn_data_wraps_yaw_and_clamps_pitch() {
724        let respawn_data = RespawnData::of(
725            Identifier::vanilla_static("overworld"),
726            BlockPos::new(1, 2, 3),
727            181.0,
728            120.0,
729        );
730
731        assert_eq!(respawn_data.yaw.to_bits(), (-179.0_f32).to_bits());
732        assert_eq!(respawn_data.pitch.to_bits(), 90.0_f32.to_bits());
733    }
734
735    #[test]
736    fn respawn_data_round_trips_through_toml() {
737        let respawn_data = RespawnData::of(
738            Identifier::vanilla_static("the_nether"),
739            BlockPos::new(-4, 70, 8),
740            -181.0,
741            -120.0,
742        );
743
744        let serialized = toml::to_string(&respawn_data).expect("respawn data should serialize");
745        let deserialized: RespawnData =
746            toml::from_str(&serialized).expect("respawn data should deserialize");
747
748        assert_eq!(
749            deserialized.global_pos.dimension,
750            Identifier::vanilla_static("the_nether")
751        );
752        assert_eq!(deserialized.pos(), BlockPos::new(-4, 70, 8));
753        assert_eq!(deserialized.yaw.to_bits(), 179.0_f32.to_bits());
754        assert_eq!(deserialized.pitch.to_bits(), (-90.0_f32).to_bits());
755    }
756
757    #[test]
758    fn level_data_uses_legacy_spawn_as_respawn_default() {
759        init_vanilla_registry();
760        let mut data = LevelData::new_with_seed(1);
761        data.set_spawn_pos(BlockPos::new(10, 65, -3));
762        data.spawn.angle = 270.0;
763
764        let respawn_data = data.respawn_data_or_local(&Identifier::vanilla_static("overworld"));
765
766        assert_eq!(
767            respawn_data.global_pos.dimension,
768            Identifier::vanilla_static("overworld")
769        );
770        assert_eq!(respawn_data.pos(), BlockPos::new(10, 65, -3));
771        assert_eq!(respawn_data.yaw.to_bits(), (-90.0_f32).to_bits());
772        assert_eq!(respawn_data.pitch.to_bits(), 0.0_f32.to_bits());
773    }
774
775    #[test]
776    #[expect(
777        clippy::float_cmp,
778        reason = "the exactly representable configured clock rate must round-trip unchanged"
779    )]
780    fn level_data_round_trips_world_clock_state() {
781        init_vanilla_registry();
782        let mut data = LevelData::new_with_seed(1);
783        assert_eq!(
784            data.world_clocks
785                .set_total_ticks(&vanilla_world_clocks::OVERWORLD, 98_765),
786            Some(())
787        );
788        assert_eq!(
789            data.world_clocks
790                .set_rate(&vanilla_world_clocks::OVERWORLD, 3.5),
791            Some(())
792        );
793
794        let serialized = toml::to_string(&data).expect("level data should serialize");
795        let mut restored: LevelData =
796            toml::from_str(&serialized).expect("level data should deserialize");
797        assert_eq!(
798            restored.world_clocks.initialize_registered_clocks(),
799            Ok(false)
800        );
801        assert_eq!(
802            restored
803                .world_clocks
804                .total_ticks(&vanilla_world_clocks::OVERWORLD),
805            Some(98_765)
806        );
807        let Some(update) = restored
808            .world_clocks
809            .network_update(&vanilla_world_clocks::OVERWORLD, true)
810        else {
811            panic!("overworld clock update should exist");
812        };
813        assert_eq!(update.3, 3.5);
814    }
815
816    #[test]
817    fn level_data_round_trips_typed_game_rules_with_untagged_toml_values() {
818        init_vanilla_registry();
819        let mut data = LevelData::new_with_seed(1);
820        assert!(
821            data.game_rules_values
822                .set(&KEEP_INVENTORY, true, &REGISTRY.game_rules,)
823        );
824        assert!(
825            data.game_rules_values
826                .set(&RANDOM_TICK_SPEED, 9, &REGISTRY.game_rules,)
827        );
828        data.save_game_rules();
829
830        let serialized = toml::to_string(&data).expect("level data should serialize");
831        assert!(serialized.contains("keep_inventory = true"));
832        assert!(serialized.contains("random_tick_speed = 9"));
833
834        let mut restored: LevelData =
835            toml::from_str(&serialized).expect("level data should deserialize");
836        restored.load_game_rules();
837
838        assert!(
839            restored
840                .game_rules_values
841                .get(&KEEP_INVENTORY, &REGISTRY.game_rules)
842        );
843        assert_eq!(
844            restored
845                .game_rules_values
846                .get(&RANDOM_TICK_SPEED, &REGISTRY.game_rules),
847            9
848        );
849    }
850}