1use 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct WorldBorderData {
33 pub center_x: f64,
35 pub center_z: f64,
37 pub damage_per_block: f64,
39 pub safe_zone: f64,
41 pub warning_blocks: i32,
43 pub warning_time: i32,
45 pub size: f64,
47 pub lerp_time: i64,
49 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#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct LevelData {
72 pub seed: i64,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 game_time: Option<i64>,
77 #[serde(default)]
79 pub(crate) world_clocks: WorldClockManager,
80 pub spawn: SpawnPoint,
82 #[serde(default)]
84 pub respawn: Option<RespawnData>,
85 pub weather: WeatherState,
87 #[serde(default)]
89 pub world_border: WorldBorderData,
90 #[serde(default)]
92 pub difficulty: Difficulty,
93 #[serde(default)]
95 pub difficulty_locked: bool,
96 pub game_rules: FxHashMap<String, serde_json::Value>,
98 #[serde(skip)]
100 pub game_rules_values: GameRuleValues,
101 pub initialized: bool,
103 #[serde(default)]
105 pub generation: Option<WorldGenerationSettings>,
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct WorldGenerationSettings {
111 pub generator: Identifier,
113 pub config: toml::Value,
115 pub dimension_type: Identifier,
117 pub min_y: i32,
119 pub height: i32,
121}
122
123#[derive(Deserialize)]
124struct SavedLevelSeed {
125 seed: i64,
126}
127
128impl WorldGenerationSettings {
129 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SpawnPoint {
169 pub x: i32,
171 pub y: i32,
173 pub z: i32,
175 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#[derive(Debug, Clone, PartialEq)]
192pub struct RespawnData {
193 pub global_pos: GlobalPos,
195 pub yaw: f32,
197 pub pitch: f32,
199}
200
201impl RespawnData {
202 #[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 #[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 #[must_use]
220 pub const fn dimension(&self) -> &Identifier {
221 &self.global_pos.dimension
222 }
223
224 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
275pub struct WeatherState {
276 pub raining: bool,
278 pub rain_time: i32,
280 pub thundering: bool,
282 pub thunder_time: i32,
284 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 #[must_use]
297 pub fn new_with_seed(seed: i64) -> Self {
298 Self::new_with_seed_and_difficulty(seed, Difficulty::default())
299 }
300
301 #[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(®ISTRY.game_rules),
316 initialized: false,
317 generation: None,
318 }
319 }
320
321 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 pub fn load_game_rules(&mut self) {
347 self.game_rules_values = GameRuleValues::new(®ISTRY.game_rules);
348 for (name, value) in &self.game_rules {
349 self.game_rules_values
350 .set_serialized_by_name(name, value, ®ISTRY.game_rules);
351 }
352 }
353
354 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, ®ISTRY.game_rules),
366 );
367 self.game_rules.insert(name, value);
368 }
369 }
370
371 #[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 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 #[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 pub fn set_respawn_data(&mut self, respawn_data: RespawnData) {
394 self.respawn = Some(respawn_data);
395 }
396}
397
398pub struct LevelDataManager {
400 path: Option<PathBuf>,
402 data: LevelData,
404 dirty: bool,
406 primary_game_time: Option<Arc<GameTime>>,
407 game_time: Arc<GameTime>,
408}
409
410impl LevelDataManager {
411 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 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 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 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 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 #[must_use]
526 pub const fn data(&self) -> &LevelData {
527 &self.data
528 }
529
530 pub const fn data_mut(&mut self) -> &mut LevelData {
532 self.dirty = true;
533 &mut self.data
534 }
535
536 #[must_use]
538 pub const fn is_dirty(&self) -> bool {
539 self.dirty
540 }
541
542 pub const fn mark_dirty(&mut self) {
544 self.dirty = true;
545 }
546
547 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 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 #[must_use]
577 pub const fn seed(&self) -> i64 {
578 self.data.seed
579 }
580
581 #[must_use]
583 pub fn game_time_handle(&self) -> Arc<GameTime> {
584 Arc::clone(&self.game_time)
585 }
586
587 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 #[must_use]
602 pub(crate) const fn world_clocks(&self) -> &WorldClockManager {
603 &self.data.world_clocks
604 }
605
606 pub(crate) const fn world_clocks_mut(&mut self) -> &mut WorldClockManager {
608 self.dirty = true;
609 &mut self.data.world_clocks
610 }
611
612 #[must_use]
614 pub const fn clear_weather_time(&self) -> i32 {
615 self.data.weather.clear_weather_time
616 }
617
618 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 #[must_use]
626 pub const fn rain_time(&self) -> i32 {
627 self.data.weather.rain_time
628 }
629
630 pub const fn set_rain_time(&mut self, time: i32) {
632 self.data.weather.rain_time = time;
633 self.dirty = true;
634 }
635
636 #[must_use]
638 pub const fn thunder_time(&self) -> i32 {
639 self.data.weather.thunder_time
640 }
641
642 pub const fn set_thunder_time(&mut self, time: i32) {
644 self.data.weather.thunder_time = time;
645 self.dirty = true;
646 }
647
648 #[must_use]
650 pub const fn is_raining(&self) -> bool {
651 self.data.weather.raining
652 }
653
654 pub const fn set_raining(&mut self, raining: bool) {
656 self.data.weather.raining = raining;
657 self.dirty = true;
658 }
659
660 #[must_use]
662 pub const fn is_thundering(&self) -> bool {
663 self.data.weather.thundering
664 }
665
666 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, ®ISTRY.game_rules,)
870 );
871 assert!(
872 data.game_rules_values
873 .set(&RANDOM_TICK_SPEED, 9, ®ISTRY.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, ®ISTRY.game_rules)
889 );
890 assert_eq!(
891 restored
892 .game_rules_values
893 .get(&RANDOM_TICK_SPEED, ®ISTRY.game_rules),
894 9
895 );
896 }
897}
898
899#[cfg(test)]
900mod game_time_tests;