1use 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
24#[serde(default)]
25pub struct WorldBorderData {
26 pub center_x: f64,
28 pub center_z: f64,
30 pub damage_per_block: f64,
32 pub safe_zone: f64,
34 pub warning_blocks: i32,
36 pub warning_time: i32,
38 pub size: f64,
40 pub lerp_time: i64,
42 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#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct LevelData {
65 pub seed: i64,
67 pub game_time: i64,
69 #[serde(default)]
71 pub(crate) world_clocks: WorldClockManager,
72 pub spawn: SpawnPoint,
74 #[serde(default)]
76 pub respawn: Option<RespawnData>,
77 pub weather: WeatherState,
79 #[serde(default)]
81 pub world_border: WorldBorderData,
82 #[serde(default)]
84 pub difficulty: Difficulty,
85 #[serde(default)]
87 pub difficulty_locked: bool,
88 pub game_rules: FxHashMap<String, serde_json::Value>,
90 #[serde(skip)]
92 pub game_rules_values: GameRuleValues,
93 pub initialized: bool,
95 #[serde(default)]
97 pub generation: Option<WorldGenerationSettings>,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct WorldGenerationSettings {
103 pub generator: Identifier,
105 pub config: toml::Value,
107 pub dimension_type: Identifier,
109 pub min_y: i32,
111 pub height: i32,
113}
114
115#[derive(Deserialize)]
116struct SavedLevelSeed {
117 seed: i64,
118}
119
120impl WorldGenerationSettings {
121 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct SpawnPoint {
161 pub x: i32,
163 pub y: i32,
165 pub z: i32,
167 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#[derive(Debug, Clone, PartialEq)]
184pub struct RespawnData {
185 pub global_pos: GlobalPos,
187 pub yaw: f32,
189 pub pitch: f32,
191}
192
193impl RespawnData {
194 #[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 #[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 #[must_use]
212 pub const fn dimension(&self) -> &Identifier {
213 &self.global_pos.dimension
214 }
215
216 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
278pub struct WeatherState {
279 pub raining: bool,
281 pub rain_time: i32,
283 pub thundering: bool,
285 pub thunder_time: i32,
287 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 #[must_use]
300 pub fn new_with_seed(seed: i64) -> Self {
301 Self::new_with_seed_and_difficulty(seed, Difficulty::default())
302 }
303
304 #[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(®ISTRY.game_rules),
319 initialized: false,
320 generation: None,
321 }
322 }
323
324 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 pub fn load_game_rules(&mut self) {
350 self.game_rules_values = GameRuleValues::new(®ISTRY.game_rules);
351 for (name, value) in &self.game_rules {
352 self.game_rules_values
353 .set_serialized_by_name(name, value, ®ISTRY.game_rules);
354 }
355 }
356
357 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, ®ISTRY.game_rules),
369 );
370 self.game_rules.insert(name, value);
371 }
372 }
373
374 #[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 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 #[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 pub fn set_respawn_data(&mut self, respawn_data: RespawnData) {
397 self.respawn = Some(respawn_data);
398 }
399}
400
401pub struct LevelDataManager {
403 path: Option<PathBuf>,
405 data: LevelData,
407 dirty: bool,
409}
410
411impl LevelDataManager {
412 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 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 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 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 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 #[must_use]
484 pub const fn data(&self) -> &LevelData {
485 &self.data
486 }
487
488 pub const fn data_mut(&mut self) -> &mut LevelData {
490 self.dirty = true;
491 &mut self.data
492 }
493
494 #[must_use]
496 pub const fn is_dirty(&self) -> bool {
497 self.dirty
498 }
499
500 pub const fn mark_dirty(&mut self) {
502 self.dirty = true;
503 }
504
505 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 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 #[must_use]
533 pub const fn seed(&self) -> i64 {
534 self.data.seed
535 }
536
537 #[must_use]
539 pub const fn game_time(&self) -> i64 {
540 self.data.game_time
541 }
542
543 pub const fn set_game_time(&mut self, time: i64) {
545 self.data.game_time = time;
546 self.dirty = true;
547 }
548
549 #[must_use]
551 pub(crate) const fn world_clocks(&self) -> &WorldClockManager {
552 &self.data.world_clocks
553 }
554
555 pub(crate) const fn world_clocks_mut(&mut self) -> &mut WorldClockManager {
557 self.dirty = true;
558 &mut self.data.world_clocks
559 }
560
561 #[must_use]
563 pub const fn clear_weather_time(&self) -> i32 {
564 self.data.weather.clear_weather_time
565 }
566
567 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 #[must_use]
575 pub const fn rain_time(&self) -> i32 {
576 self.data.weather.rain_time
577 }
578
579 pub const fn set_rain_time(&mut self, time: i32) {
581 self.data.weather.rain_time = time;
582 self.dirty = true;
583 }
584
585 #[must_use]
587 pub const fn thunder_time(&self) -> i32 {
588 self.data.weather.thunder_time
589 }
590
591 pub const fn set_thunder_time(&mut self, time: i32) {
593 self.data.weather.thunder_time = time;
594 self.dirty = true;
595 }
596
597 #[must_use]
599 pub const fn is_raining(&self) -> bool {
600 self.data.weather.raining
601 }
602
603 pub const fn set_raining(&mut self, raining: bool) {
605 self.data.weather.raining = raining;
606 self.dirty = true;
607 }
608
609 #[must_use]
611 pub const fn is_thundering(&self) -> bool {
612 self.data.weather.thundering
613 }
614
615 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, ®ISTRY.game_rules,)
823 );
824 assert!(
825 data.game_rules_values
826 .set(&RANDOM_TICK_SPEED, 9, ®ISTRY.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, ®ISTRY.game_rules)
842 );
843 assert_eq!(
844 restored
845 .game_rules_values
846 .get(&RANDOM_TICK_SPEED, ®ISTRY.game_rules),
847 9
848 );
849 }
850}