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