1use rustc_hash::{FxHashMap, FxHashSet};
8use serde::{Deserialize, Deserializer, de::Error as DeError};
9use std::{
10 collections::BTreeMap,
11 path::{Component, Path, PathBuf},
12};
13pub use steel_protocol::packet_traits::CompressionInfo;
14use steel_protocol::packets::config::{CServerLinks, Link, ServerLinksType};
15use steel_registry::vanilla_dimension_types;
16use steel_utils::Identifier;
17use steel_utils::codec::Or;
18use steel_utils::types::{Difficulty, GameType};
19use text_components::TextComponent;
20use toml::map::Map;
21
22use crate::chunk_saver::registry::WorldStorageRegistry;
23use crate::worldgen::registry::{ValidatedWorldGeneratorConfig, WorldGeneratorRegistry};
24
25pub const ONLINE_MODE_REQUIRES_ENCRYPTION: &str =
27 "encryption must be true when online_mode is enabled";
28
29pub const DEFAULT_SPAM_THRESHOLD_SECONDS: i32 = 10;
31
32pub const DEFAULT_MAX_CHAINED_NEIGHBOR_UPDATES: i32 = 1_000_000;
34
35pub const fn validate_login_security(
42 online_mode: bool,
43 encryption: bool,
44) -> Result<(), &'static str> {
45 if online_mode && !encryption {
46 Err(ONLINE_MODE_REQUIRES_ENCRYPTION)
47 } else {
48 Ok(())
49 }
50}
51
52#[derive(Debug, Clone)]
56pub struct RuntimeConfig {
57 pub max_players: u32,
59 pub view_distance: u8,
61 pub simulation_distance: u8,
63 pub max_chained_neighbor_updates: i32,
65 pub online_mode: bool,
67 pub auth_server: Option<String>,
69 pub profile_server: Option<String>,
71 pub services_server: Option<String>,
73 pub encryption: bool,
75 pub allow_flight: bool,
77 pub motd: String,
79 pub use_favicon: bool,
81 pub favicon: String,
83 pub enforce_secure_chat: bool,
85 pub chat_spam_threshold_seconds: i32,
87 pub command_spam_threshold_seconds: i32,
89 pub compression: Option<CompressionInfo>,
91 pub server_links: Option<ServerLinks>,
93 pub packet_workers: Option<usize>,
95 pub chunk_generation_threads: Option<usize>,
97 pub chunk_encoding_threads: Option<usize>,
99}
100
101impl Default for RuntimeConfig {
102 fn default() -> Self {
105 Self {
106 max_players: 20,
107 view_distance: 10,
108 simulation_distance: 10,
109 max_chained_neighbor_updates: DEFAULT_MAX_CHAINED_NEIGHBOR_UPDATES,
110 online_mode: true,
111 auth_server: None,
112 profile_server: None,
113 services_server: None,
114 encryption: true,
115 allow_flight: false,
116 motd: "A Steel Server".to_owned(),
117 use_favicon: true,
118 favicon: "config/favicon.png".to_owned(),
119 enforce_secure_chat: false,
120 chat_spam_threshold_seconds: DEFAULT_SPAM_THRESHOLD_SECONDS,
121 command_spam_threshold_seconds: DEFAULT_SPAM_THRESHOLD_SECONDS,
122 compression: Some(CompressionInfo::default()),
123 server_links: None,
124 packet_workers: None,
125 chunk_generation_threads: None,
126 chunk_encoding_threads: None,
127 }
128 }
129}
130
131impl RuntimeConfig {
132 #[must_use]
134 pub fn server_links_packet(&self) -> Option<CServerLinks> {
135 let server_links = self.server_links.as_ref()?;
136
137 if !server_links.enable || server_links.links.is_empty() {
138 return None;
139 }
140
141 let links: Vec<Link> = server_links
142 .links
143 .iter()
144 .map(|config_link| {
145 let label = match &config_link.label {
146 ConfigLabel::BuiltIn(link_type) => Or::Left(*link_type),
147 ConfigLabel::Custom(text_component) => Or::Right(text_component.clone()),
148 };
149 Link::new(label, config_link.url.clone())
150 })
151 .collect();
152
153 Some(CServerLinks { links })
154 }
155}
156
157#[derive(Debug, Clone, Deserialize)]
159#[serde(untagged)]
160#[expect(
161 clippy::large_enum_variant,
162 reason = "TextComponent variant is common; boxing would add indirection for every use"
163)]
164pub enum ConfigLabel {
165 BuiltIn(ServerLinksType),
167 Custom(TextComponent),
169}
170
171#[derive(Debug, Clone, Deserialize)]
173pub struct ConfigLink {
174 pub label: ConfigLabel,
176 pub url: String,
178}
179
180#[derive(Debug, Clone, Deserialize, Default)]
182#[serde(default)]
183pub struct ServerLinks {
184 pub enable: bool,
186 #[serde(default)]
188 pub links: Vec<ConfigLink>,
189}
190
191#[derive(Debug, Clone)]
193pub enum WorldStorageConfig {
194 Disk {
196 path: String,
198 },
199 RamOnly,
202}
203
204#[derive(Debug, Clone, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct WorldsConfig {
208 #[serde(default = "default_save_path")]
210 pub save_path: String,
211 #[serde(default)]
213 pub seed: Option<String>,
214 #[serde(default, deserialize_with = "deserialize_optional_game_type")]
216 pub default_gamemode: Option<GameType>,
217 #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
219 pub difficulty: Option<Difficulty>,
220 #[serde(default)]
222 pub storage: Option<StorageSelection>,
223 #[serde(default)]
225 pub player_storage: Option<StorageSelection>,
226 pub domains: BTreeMap<String, DomainConfig>,
228}
229
230#[derive(Debug, Clone, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct DomainConfig {
234 #[serde(default)]
236 pub default: bool,
237 #[serde(default)]
239 pub seed: Option<String>,
240 #[serde(default, deserialize_with = "deserialize_optional_game_type")]
242 pub default_gamemode: Option<GameType>,
243 #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
245 pub difficulty: Option<Difficulty>,
246 #[serde(default)]
248 pub storage: Option<StorageSelection>,
249 #[serde(default)]
251 pub worlds: Vec<WorldEntryConfig>,
252}
253
254#[derive(Debug, Clone, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct WorldEntryConfig {
258 pub name: String,
260 pub generator: Identifier,
262 #[serde(default)]
264 pub default: bool,
265 #[serde(default)]
267 pub seed: Option<String>,
268 #[serde(default, deserialize_with = "deserialize_optional_game_type")]
270 pub default_gamemode: Option<GameType>,
271 #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
273 pub difficulty: Option<Difficulty>,
274 #[serde(default)]
276 pub storage: Option<StorageSelection>,
277 #[serde(default)]
279 pub nether_portal_target: Option<String>,
280 #[serde(default)]
282 pub end_portal_target: Option<String>,
283 #[serde(default)]
285 pub config: Option<toml::Value>,
286}
287
288#[derive(Debug, Clone, Deserialize)]
290#[serde(deny_unknown_fields)]
291pub struct StorageSelection {
292 #[serde(rename = "type")]
294 pub kind: Identifier,
295 #[serde(default)]
297 pub config: Option<toml::Value>,
298}
299
300impl StorageSelection {
301 #[must_use]
303 pub fn default_world_disk() -> Self {
304 Self {
305 kind: Identifier::from_steel("disk"),
306 config: None,
307 }
308 }
309
310 #[must_use]
312 pub fn default_player_file() -> Self {
313 Self {
314 kind: Identifier::from_steel("file"),
315 config: None,
316 }
317 }
318
319 #[must_use]
321 pub fn config_value(&self) -> toml::Value {
322 self.config
323 .clone()
324 .unwrap_or_else(|| toml::Value::Table(Map::new()))
325 }
326}
327
328#[derive(Debug, Clone)]
330pub struct ResolvedWorldsConfig {
331 pub save_path: PathBuf,
333 pub default_domain: String,
335 pub domains: Vec<ResolvedDomainConfig>,
337 pub worlds: Vec<ResolvedWorldConfig>,
339 pub player_storage: StorageSelection,
341}
342
343#[derive(Debug, Clone)]
345pub struct ResolvedDomainConfig {
346 pub name: String,
348 pub default_world: Identifier,
350 pub worlds: Vec<Identifier>,
352}
353
354#[derive(Debug, Clone)]
356pub struct ResolvedWorldConfig {
357 pub key: Identifier,
359 pub domain: String,
361 pub name: String,
363 pub generator: Identifier,
365 pub generator_config: ValidatedWorldGeneratorConfig,
367 pub seed: i64,
369 pub default_gamemode: GameType,
371 pub difficulty: Difficulty,
373 pub storage: StorageSelection,
375 pub nether_portal_target: Option<Identifier>,
377 pub end_portal_target: Option<Identifier>,
379}
380
381impl WorldsConfig {
382 pub fn validate_and_resolve(
387 &self,
388 generator_registry: &WorldGeneratorRegistry,
389 storage_registry: &WorldStorageRegistry,
390 ) -> Result<ResolvedWorldsConfig, String> {
391 validate_clean_path(&self.save_path, "save_path")?;
394
395 if self.domains.is_empty() {
396 return Err("worlds.toml must declare at least one domain".to_owned());
397 }
398
399 let root_defaults = RootWorldDefaults {
400 seed: seed_from_config(self.seed.as_deref().unwrap_or("")),
401 gamemode: self.default_gamemode.unwrap_or(GameType::Survival),
402 difficulty: self.difficulty.unwrap_or(Difficulty::Normal),
403 };
404 let root_storage = self
405 .storage
406 .clone()
407 .unwrap_or_else(StorageSelection::default_world_disk);
408 storage_registry.validate_selection(&root_storage)?;
409
410 let player_storage = self
411 .player_storage
412 .clone()
413 .unwrap_or_else(StorageSelection::default_player_file);
414 validate_player_storage_selection(&player_storage)?;
415
416 let mut default_domain = None;
417 let mut resolved_domains = Vec::with_capacity(self.domains.len());
418 let mut resolved_worlds = Vec::new();
419
420 for (domain_name, domain) in &self.domains {
421 if domain.default && default_domain.replace(domain_name.clone()).is_some() {
422 return Err("worlds.toml must declare exactly one default domain".to_owned());
423 }
424 let (resolved_domain, mut domain_worlds) = resolve_domain_config(
425 domain_name,
426 domain,
427 root_defaults,
428 &root_storage,
429 generator_registry,
430 storage_registry,
431 )?;
432 resolved_domains.push(resolved_domain);
433 resolved_worlds.append(&mut domain_worlds);
434 }
435
436 if resolved_worlds.is_empty() {
437 return Err("worlds.toml must declare at least one world".to_owned());
438 }
439
440 let Some(default_domain) = default_domain else {
441 return Err("worlds.toml must declare exactly one default domain".to_owned());
442 };
443
444 Ok(ResolvedWorldsConfig {
445 save_path: PathBuf::from(&self.save_path),
446 default_domain,
447 domains: resolved_domains,
448 worlds: resolved_worlds,
449 player_storage,
450 })
451 }
452}
453
454fn resolve_domain_config(
455 domain_name: &str,
456 domain: &DomainConfig,
457 root_defaults: RootWorldDefaults,
458 root_storage: &StorageSelection,
459 generator_registry: &WorldGeneratorRegistry,
460 storage_registry: &WorldStorageRegistry,
461) -> Result<(ResolvedDomainConfig, Vec<ResolvedWorldConfig>), String> {
462 validate_domain_name(domain_name)?;
463 if domain.worlds.is_empty() {
464 return Err(format!(
465 "domain {domain_name} must declare at least one world"
466 ));
467 }
468
469 let domain_defaults = DomainWorldDefaults::from_config(domain, root_defaults);
470 let domain_storage = domain
471 .storage
472 .clone()
473 .unwrap_or_else(|| root_storage.clone());
474 storage_registry.validate_selection(&domain_storage)?;
475
476 let mut seen_world_names = FxHashSet::default();
477 let mut default_world = None;
478 let mut domain_world_ids = Vec::with_capacity(domain.worlds.len());
479 let mut resolved_worlds = Vec::with_capacity(domain.worlds.len());
480
481 for world in &domain.worlds {
482 let resolved_world = resolve_world_config(
483 domain_name,
484 world,
485 domain_defaults,
486 &domain_storage,
487 generator_registry,
488 storage_registry,
489 )?;
490
491 if !seen_world_names.insert(world.name.clone()) {
492 return Err(format!(
493 "domain {domain_name} declares duplicate world {}",
494 world.name
495 ));
496 }
497 if world.default && default_world.replace(resolved_world.key.clone()).is_some() {
498 return Err(format!(
499 "domain {domain_name} must declare exactly one default world"
500 ));
501 }
502
503 domain_world_ids.push(resolved_world.key.clone());
504 resolved_worlds.push(resolved_world);
505 }
506
507 validate_explicit_portal_targets(domain_name, &resolved_worlds, &seen_world_names)?;
508
509 let Some(default_world) = default_world else {
510 return Err(format!(
511 "domain {domain_name} must declare exactly one default world"
512 ));
513 };
514
515 Ok((
516 ResolvedDomainConfig {
517 name: domain_name.to_owned(),
518 default_world,
519 worlds: domain_world_ids,
520 },
521 resolved_worlds,
522 ))
523}
524
525fn resolve_world_config(
526 domain_name: &str,
527 world: &WorldEntryConfig,
528 domain_defaults: DomainWorldDefaults,
529 domain_storage: &StorageSelection,
530 generator_registry: &WorldGeneratorRegistry,
531 storage_registry: &WorldStorageRegistry,
532) -> Result<ResolvedWorldConfig, String> {
533 validate_world_name(&world.name, domain_name)?;
534 let world_key = Identifier::new(domain_name.to_owned(), world.name.clone());
535
536 let raw_generator_config = world
537 .config
538 .clone()
539 .unwrap_or_else(|| toml::Value::Table(Map::new()));
540 let generator_config =
541 generator_registry.validate_config(&world.generator, &raw_generator_config)?;
542
543 let storage = world
544 .storage
545 .clone()
546 .unwrap_or_else(|| domain_storage.clone());
547 storage_registry.validate_selection(&storage)?;
548 let nether_portal_target = resolve_explicit_portal_target(
549 domain_name,
550 &world.name,
551 world.nether_portal_target.as_deref(),
552 "nether_portal_target",
553 )?;
554 let end_portal_target = resolve_explicit_portal_target(
555 domain_name,
556 &world.name,
557 world.end_portal_target.as_deref(),
558 "end_portal_target",
559 )?;
560 validate_end_portal_target_dimension(
561 domain_name,
562 &world.name,
563 end_portal_target.as_ref(),
564 &generator_config,
565 )?;
566
567 Ok(ResolvedWorldConfig {
568 key: world_key,
569 domain: domain_name.to_owned(),
570 name: world.name.clone(),
571 generator: world.generator.clone(),
572 generator_config,
573 seed: world
574 .seed
575 .as_deref()
576 .map_or(domain_defaults.seed, seed_from_config),
577 default_gamemode: world.default_gamemode.unwrap_or(domain_defaults.gamemode),
578 difficulty: world.difficulty.unwrap_or(domain_defaults.difficulty),
579 storage,
580 nether_portal_target,
581 end_portal_target,
582 })
583}
584
585fn resolve_explicit_portal_target(
586 domain_name: &str,
587 source_world_name: &str,
588 target_world_name: Option<&str>,
589 field: &str,
590) -> Result<Option<Identifier>, String> {
591 let Some(target_world_name) = target_world_name else {
592 return Ok(None);
593 };
594 if target_world_name.is_empty()
595 || target_world_name.contains('/')
596 || !Identifier::validate_path(target_world_name)
597 {
598 return Err(format!(
599 "invalid {field} {target_world_name} for world {domain_name}:{source_world_name}"
600 ));
601 }
602 if target_world_name == source_world_name {
603 return Err(format!(
604 "world {domain_name}:{source_world_name} {field} must not target itself"
605 ));
606 }
607
608 Ok(Some(Identifier::new(
609 domain_name.to_owned(),
610 target_world_name.to_owned(),
611 )))
612}
613
614fn validate_end_portal_target_dimension(
615 domain_name: &str,
616 source_world_name: &str,
617 end_portal_target: Option<&Identifier>,
618 generator_config: &ValidatedWorldGeneratorConfig,
619) -> Result<(), String> {
620 if end_portal_target.is_some()
621 && generator_config.dimension_type() == &vanilla_dimension_types::THE_END
622 {
623 return Err(format!(
624 "world {domain_name}:{source_world_name} end_portal_target is invalid on End-dimension worlds; End portal returns use respawn data"
625 ));
626 }
627
628 Ok(())
629}
630
631fn validate_explicit_portal_targets(
632 domain_name: &str,
633 worlds: &[ResolvedWorldConfig],
634 world_names: &FxHashSet<String>,
635) -> Result<(), String> {
636 let worlds_by_name = worlds
637 .iter()
638 .map(|world| (world.name.as_str(), world))
639 .collect::<FxHashMap<_, _>>();
640 for world in worlds {
641 validate_explicit_portal_target(
642 domain_name,
643 &world.key,
644 world.nether_portal_target.as_ref(),
645 "nether_portal_target",
646 world_names,
647 )?;
648 validate_explicit_portal_target(
649 domain_name,
650 &world.key,
651 world.end_portal_target.as_ref(),
652 "end_portal_target",
653 world_names,
654 )?;
655 validate_end_portal_target_target_dimension(domain_name, world, &worlds_by_name)?;
656 }
657
658 Ok(())
659}
660
661fn validate_explicit_portal_target(
662 domain_name: &str,
663 source_world: &Identifier,
664 target: Option<&Identifier>,
665 field: &str,
666 world_names: &FxHashSet<String>,
667) -> Result<(), String> {
668 let Some(target) = target else {
669 return Ok(());
670 };
671 if world_names.contains(target.path.as_ref()) {
672 return Ok(());
673 }
674
675 Err(format!(
676 "world {source_world} {field} target {} is not declared in domain {domain_name}",
677 target.path
678 ))
679}
680
681fn validate_end_portal_target_target_dimension(
682 domain_name: &str,
683 source_world: &ResolvedWorldConfig,
684 worlds_by_name: &FxHashMap<&str, &ResolvedWorldConfig>,
685) -> Result<(), String> {
686 let Some(target) = &source_world.end_portal_target else {
687 return Ok(());
688 };
689 let Some(target_world) = worlds_by_name.get(target.path.as_ref()) else {
690 return Ok(());
691 };
692
693 if target_world.generator_config.dimension_type() == &vanilla_dimension_types::THE_END {
694 return Ok(());
695 }
696
697 Err(format!(
698 "world {domain_name}:{} end_portal_target {} must target an End-dimension world",
699 source_world.name, target.path
700 ))
701}
702
703#[derive(Clone, Copy)]
704struct RootWorldDefaults {
705 seed: i64,
706 gamemode: GameType,
707 difficulty: Difficulty,
708}
709
710#[derive(Clone, Copy)]
711struct DomainWorldDefaults {
712 seed: i64,
713 gamemode: GameType,
714 difficulty: Difficulty,
715}
716
717impl DomainWorldDefaults {
718 fn from_config(domain: &DomainConfig, root: RootWorldDefaults) -> Self {
719 Self {
720 seed: domain.seed.as_deref().map_or(root.seed, seed_from_config),
721 gamemode: domain.default_gamemode.unwrap_or(root.gamemode),
722 difficulty: domain.difficulty.unwrap_or(root.difficulty),
723 }
724 }
725}
726
727fn default_save_path() -> String {
728 "saves".to_owned()
729}
730
731fn validate_domain_name(name: &str) -> Result<(), String> {
732 if name == "global" {
733 return Err("domain name global is reserved".to_owned());
734 }
735 if name.is_empty() || !Identifier::validate_namespace(name) {
736 return Err(format!("invalid domain name {name}"));
737 }
738 Ok(())
739}
740
741fn validate_world_name(name: &str, domain: &str) -> Result<(), String> {
742 if name.is_empty() || name.contains('/') || !Identifier::validate_path(name) {
743 return Err(format!("invalid world name {name} in domain {domain}"));
744 }
745 Ok(())
746}
747
748pub fn validate_clean_path(path: &str, field: &str) -> Result<(), String> {
753 let path = Path::new(path);
754 if path.as_os_str().is_empty() {
755 return Err(format!("{field} must not be empty"));
756 }
757 let absolute = path.is_absolute();
758 let clean = path.components().all(|component| match component {
759 Component::Normal(_) => true,
760 Component::RootDir | Component::Prefix(_) => absolute,
761 Component::CurDir | Component::ParentDir => false,
762 });
763 if !clean {
764 return Err(format!("{field} must be a clean path"));
765 }
766 Ok(())
767}
768
769pub fn validate_relative_path(path: &str, field: &str) -> Result<(), String> {
771 if Path::new(path).is_absolute() {
772 return Err(format!("{field} must be relative"));
773 }
774 validate_clean_path(path, field)
775}
776
777fn validate_player_storage_selection(selection: &StorageSelection) -> Result<(), String> {
778 if selection.kind != Identifier::from_steel("file")
779 && selection.kind != Identifier::from_steel("ram")
780 {
781 return Err(format!("unknown player storage {}", selection.kind));
782 }
783 if selection.config.is_some() {
784 return Err(format!(
785 "{} player storage does not accept config yet",
786 selection.kind
787 ));
788 }
789 Ok(())
790}
791
792fn seed_from_config(seed: &str) -> i64 {
793 if seed.is_empty() {
794 rand::random()
795 } else {
796 seed.parse().unwrap_or_else(|_| {
797 let mut hash: i64 = 0;
798 for byte in seed.bytes() {
799 hash = hash.wrapping_mul(31).wrapping_add(i64::from(byte));
800 }
801 hash
802 })
803 }
804}
805
806fn deserialize_optional_game_type<'de, D>(deserializer: D) -> Result<Option<GameType>, D::Error>
807where
808 D: Deserializer<'de>,
809{
810 let Some(value) = Option::<String>::deserialize(deserializer)? else {
811 return Ok(None);
812 };
813 parse_game_type(&value).map(Some).map_err(DeError::custom)
814}
815
816fn parse_game_type(value: &str) -> Result<GameType, String> {
817 match value {
818 "survival" => Ok(GameType::Survival),
819 "creative" => Ok(GameType::Creative),
820 "adventure" => Ok(GameType::Adventure),
821 "spectator" => Ok(GameType::Spectator),
822 _ => Err(format!("invalid gamemode {value}")),
823 }
824}
825
826fn deserialize_optional_difficulty<'de, D>(deserializer: D) -> Result<Option<Difficulty>, D::Error>
827where
828 D: Deserializer<'de>,
829{
830 let Some(value) = Option::<String>::deserialize(deserializer)? else {
831 return Ok(None);
832 };
833 parse_difficulty(&value).map(Some).map_err(DeError::custom)
834}
835
836fn parse_difficulty(value: &str) -> Result<Difficulty, String> {
837 match value {
838 "peaceful" => Ok(Difficulty::Peaceful),
839 "easy" => Ok(Difficulty::Easy),
840 "normal" => Ok(Difficulty::Normal),
841 "hard" => Ok(Difficulty::Hard),
842 _ => Err(format!("invalid difficulty {value}")),
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use std::env::temp_dir;
850 use steel_registry::init_vanilla_registry;
851
852 #[test]
853 fn online_mode_requires_the_authenticated_encryption_flow() {
854 assert_eq!(validate_login_security(true, true), Ok(()));
855 assert_eq!(
856 validate_login_security(true, false),
857 Err(ONLINE_MODE_REQUIRES_ENCRYPTION)
858 );
859 assert_eq!(validate_login_security(false, true), Ok(()));
860 assert_eq!(validate_login_security(false, false), Ok(()));
861 }
862
863 fn registries() -> (WorldGeneratorRegistry, WorldStorageRegistry) {
864 init_vanilla_registry();
865 let generators = WorldGeneratorRegistry::new_with_builtins()
866 .expect("built-in generator registry should initialize");
867 let storage = WorldStorageRegistry::new_with_builtins()
868 .expect("built-in storage registry should initialize");
869 (generators, storage)
870 }
871
872 fn resolve(input: &str) -> Result<ResolvedWorldsConfig, String> {
873 let config: WorldsConfig = toml::from_str(input).expect("worlds config should parse");
874 let (generators, storage) = registries();
875 config.validate_and_resolve(&generators, &storage)
876 }
877
878 #[test]
879 fn resolves_cascaded_world_defaults() {
880 let resolved = resolve(
881 r#"
882save_path = "saves"
883seed = "1"
884default_gamemode = "survival"
885difficulty = "normal"
886
887[storage]
888type = "steel:disk"
889
890[domains.minecraft]
891default = true
892seed = "2"
893default_gamemode = "adventure"
894difficulty = "peaceful"
895
896[[domains.minecraft.worlds]]
897name = "overworld"
898generator = "minecraft:overworld"
899default = true
900
901[[domains.minecraft.worlds]]
902name = "the_nether"
903generator = "minecraft:the_nether"
904seed = "3"
905default_gamemode = "creative"
906difficulty = "hard"
907"#,
908 )
909 .expect("valid worlds config should resolve");
910
911 assert_eq!(resolved.default_domain, "minecraft");
912 assert_eq!(resolved.worlds.len(), 2);
913 let overworld = resolved
914 .worlds
915 .iter()
916 .find(|world| world.name == "overworld")
917 .expect("overworld should exist");
918 assert_eq!(overworld.seed, 2);
919 assert_eq!(overworld.default_gamemode, GameType::Adventure);
920 assert_eq!(overworld.difficulty, Difficulty::Peaceful);
921
922 let nether = resolved
923 .worlds
924 .iter()
925 .find(|world| world.name == "the_nether")
926 .expect("nether should exist");
927 assert_eq!(nether.seed, 3);
928 assert_eq!(nether.default_gamemode, GameType::Creative);
929 assert_eq!(nether.difficulty, Difficulty::Hard);
930 }
931
932 #[test]
933 fn resolves_selectable_player_storage_backends() {
934 const WORLDS: &str = r#"
935[player_storage]
936type = "%KIND%"
937
938[domains.minecraft]
939default = true
940
941[[domains.minecraft.worlds]]
942name = "overworld"
943generator = "minecraft:overworld"
944default = true
945"#;
946
947 for kind in ["steel:file", "steel:ram"] {
948 let resolved = resolve(&WORLDS.replace("%KIND%", kind))
949 .unwrap_or_else(|error| panic!("{kind} player storage should resolve: {error}"));
950 assert_eq!(resolved.player_storage.kind.to_string(), kind);
951 }
952
953 assert_eq!(
954 resolve(&WORLDS.replace("%KIND%", "steel:elsewhere")).err(),
955 Some("unknown player storage steel:elsewhere".to_owned())
956 );
957 }
958
959 #[test]
960 fn resolves_explicit_portal_targets() {
961 let resolved = resolve(
962 r#"
963[domains.minecraft]
964default = true
965
966[[domains.minecraft.worlds]]
967name = "overworld2"
968generator = "minecraft:overworld"
969default = true
970nether_portal_target = "the_nether2"
971end_portal_target = "the_end2"
972
973[[domains.minecraft.worlds]]
974name = "the_nether2"
975generator = "minecraft:the_nether"
976nether_portal_target = "overworld2"
977
978[[domains.minecraft.worlds]]
979name = "the_end2"
980generator = "minecraft:the_end"
981"#,
982 )
983 .expect("explicit portal targets should resolve");
984
985 let overworld = resolved
986 .worlds
987 .iter()
988 .find(|world| world.name == "overworld2")
989 .expect("overworld2 should exist");
990 assert_eq!(
991 overworld.nether_portal_target,
992 Some(Identifier::new("minecraft", "the_nether2"))
993 );
994 assert_eq!(
995 overworld.end_portal_target,
996 Some(Identifier::new("minecraft", "the_end2"))
997 );
998
999 let nether = resolved
1000 .worlds
1001 .iter()
1002 .find(|world| world.name == "the_nether2")
1003 .expect("the_nether2 should exist");
1004 assert_eq!(
1005 nether.nether_portal_target,
1006 Some(Identifier::new("minecraft", "overworld2"))
1007 );
1008 assert_eq!(nether.end_portal_target, None);
1009 }
1010
1011 #[test]
1012 fn rejects_missing_explicit_portal_target() {
1013 let error = resolve(
1014 r#"
1015[domains.minecraft]
1016default = true
1017
1018[[domains.minecraft.worlds]]
1019name = "overworld"
1020generator = "minecraft:overworld"
1021default = true
1022nether_portal_target = "missing"
1023"#,
1024 )
1025 .expect_err("missing explicit portal target should be rejected");
1026 assert!(error.contains("nether_portal_target"));
1027 assert!(error.contains("missing"));
1028 }
1029
1030 #[test]
1031 fn rejects_self_explicit_portal_target() {
1032 let error = resolve(
1033 r#"
1034[domains.minecraft]
1035default = true
1036
1037[[domains.minecraft.worlds]]
1038name = "overworld"
1039generator = "minecraft:overworld"
1040default = true
1041end_portal_target = "overworld"
1042"#,
1043 )
1044 .expect_err("self explicit portal target should be rejected");
1045 assert!(error.contains("end_portal_target"));
1046 assert!(error.contains("must not target itself"));
1047 }
1048
1049 #[test]
1050 fn rejects_end_portal_target_on_end_dimension_world() {
1051 let error = resolve(
1052 r#"
1053[domains.minecraft]
1054default = true
1055
1056[[domains.minecraft.worlds]]
1057name = "overworld"
1058generator = "minecraft:overworld"
1059default = true
1060
1061[[domains.minecraft.worlds]]
1062name = "the_end"
1063generator = "minecraft:the_end"
1064end_portal_target = "overworld"
1065"#,
1066 )
1067 .expect_err("End-dimension world end_portal_target should be rejected");
1068 assert!(error.contains("end_portal_target"));
1069 assert!(error.contains("End-dimension worlds"));
1070 }
1071
1072 #[test]
1073 fn rejects_end_portal_target_on_flat_end_dimension_world() {
1074 let error = resolve(
1075 r#"
1076[domains.minecraft]
1077default = true
1078
1079[[domains.minecraft.worlds]]
1080name = "overworld"
1081generator = "minecraft:overworld"
1082default = true
1083
1084[[domains.minecraft.worlds]]
1085name = "flat_end"
1086generator = "minecraft:flat"
1087end_portal_target = "overworld"
1088
1089[domains.minecraft.worlds.config]
1090dimension_type = "minecraft:the_end"
1091"#,
1092 )
1093 .expect_err("flat End-dimension world end_portal_target should be rejected");
1094 assert!(error.contains("end_portal_target"));
1095 assert!(error.contains("End-dimension worlds"));
1096 }
1097
1098 #[test]
1099 fn rejects_end_portal_target_to_non_end_dimension_world() {
1100 let error = resolve(
1101 r#"
1102[domains.minecraft]
1103default = true
1104
1105[[domains.minecraft.worlds]]
1106name = "overworld"
1107generator = "minecraft:overworld"
1108default = true
1109end_portal_target = "other_overworld"
1110
1111[[domains.minecraft.worlds]]
1112name = "other_overworld"
1113generator = "minecraft:overworld"
1114"#,
1115 )
1116 .expect_err("end_portal_target should require an End-dimension target");
1117 assert!(error.contains("end_portal_target"));
1118 assert!(error.contains("End-dimension world"));
1119 }
1120
1121 #[test]
1122 fn accepts_end_portal_target_to_flat_end_dimension_world() {
1123 let resolved = resolve(
1124 r#"
1125[domains.minecraft]
1126default = true
1127
1128[[domains.minecraft.worlds]]
1129name = "overworld"
1130generator = "minecraft:overworld"
1131default = true
1132end_portal_target = "flat_end"
1133
1134[[domains.minecraft.worlds]]
1135name = "flat_end"
1136generator = "minecraft:flat"
1137
1138[domains.minecraft.worlds.config]
1139dimension_type = "minecraft:the_end"
1140"#,
1141 )
1142 .expect("flat End-dimension end_portal_target should resolve");
1143 let overworld = resolved
1144 .worlds
1145 .iter()
1146 .find(|world| world.name == "overworld")
1147 .expect("overworld should exist");
1148 assert_eq!(
1149 overworld.end_portal_target,
1150 Some(Identifier::new("minecraft", "flat_end"))
1151 );
1152 }
1153
1154 #[test]
1155 fn rejects_cross_domain_explicit_portal_target() {
1156 let error = resolve(
1157 r#"
1158[domains.minecraft]
1159default = true
1160
1161[[domains.minecraft.worlds]]
1162name = "overworld"
1163generator = "minecraft:overworld"
1164default = true
1165nether_portal_target = "other:the_nether"
1166"#,
1167 )
1168 .expect_err("cross-domain-looking portal target should be rejected");
1169 assert!(error.contains("nether_portal_target"));
1170 }
1171
1172 #[test]
1173 fn rejects_reserved_global_domain() {
1174 let error = resolve(
1175 r#"
1176[domains.global]
1177default = true
1178
1179[[domains.global.worlds]]
1180name = "overworld"
1181generator = "minecraft:overworld"
1182default = true
1183"#,
1184 )
1185 .expect_err("global domain should be rejected");
1186 assert!(error.contains("reserved"));
1187 }
1188
1189 #[test]
1190 fn rejects_duplicate_world_names() {
1191 let error = resolve(
1192 r#"
1193[domains.minecraft]
1194default = true
1195
1196[[domains.minecraft.worlds]]
1197name = "overworld"
1198generator = "minecraft:overworld"
1199default = true
1200
1201[[domains.minecraft.worlds]]
1202name = "overworld"
1203generator = "minecraft:overworld"
1204"#,
1205 )
1206 .expect_err("duplicate world names should be rejected");
1207 assert!(error.contains("duplicate world"));
1208 }
1209
1210 #[test]
1211 fn rejects_missing_default_world() {
1212 let error = resolve(
1213 r#"
1214[domains.minecraft]
1215default = true
1216
1217[[domains.minecraft.worlds]]
1218name = "overworld"
1219generator = "minecraft:overworld"
1220"#,
1221 )
1222 .expect_err("missing default world should be rejected");
1223 assert!(error.contains("default world"));
1224 }
1225
1226 #[test]
1227 fn rejects_unknown_generator() {
1228 let error = resolve(
1229 r#"
1230[domains.minecraft]
1231default = true
1232
1233[[domains.minecraft.worlds]]
1234name = "overworld"
1235generator = "example:unknown"
1236default = true
1237"#,
1238 )
1239 .expect_err("unknown generator should be rejected");
1240 assert!(error.contains("unknown world generator"));
1241 }
1242
1243 #[test]
1244 fn flat_generator_config_is_optional() {
1245 let resolved = resolve(
1246 r#"
1247[domains.minecraft]
1248default = true
1249
1250[[domains.minecraft.worlds]]
1251name = "overworld"
1252generator = "minecraft:flat"
1253default = true
1254"#,
1255 )
1256 .expect("flat generator should default its config");
1257
1258 assert_eq!(
1259 resolved.worlds[0].generator,
1260 Identifier::vanilla_static("flat")
1261 );
1262 }
1263
1264 fn worlds_config_with_save_path(save_path: &str) -> Result<ResolvedWorldsConfig, String> {
1265 resolve(&format!(
1266 r#"
1267save_path = '{save_path}'
1268
1269[domains.minecraft]
1270default = true
1271
1272[[domains.minecraft.worlds]]
1273name = "overworld"
1274generator = "minecraft:flat"
1275default = true
1276"#
1277 ))
1278 }
1279
1280 #[test]
1281 fn resolves_an_absolute_save_path() {
1282 let root = temp_dir().join("steel-absolute-save-root");
1283 let resolved = worlds_config_with_save_path(&root.display().to_string())
1284 .expect("an absolute save path should resolve");
1285
1286 assert_eq!(resolved.save_path, root);
1287 }
1288
1289 #[test]
1290 fn rejects_traversal_in_a_save_path() {
1291 let relative = worlds_config_with_save_path("../saves")
1292 .expect_err("a relative save path with .. should be rejected");
1293 assert!(relative.contains("save_path must be a clean path"));
1294
1295 let absolute = temp_dir().join("..").join("saves");
1296 let absolute = worlds_config_with_save_path(&absolute.display().to_string())
1297 .expect_err("an absolute save path with .. should be rejected");
1298 assert!(absolute.contains("save_path must be a clean path"));
1299 }
1300
1301 #[test]
1302 fn rejects_an_empty_save_path() {
1303 let error = worlds_config_with_save_path("").expect_err("an empty save path is not a root");
1304 assert!(error.contains("save_path must not be empty"));
1305 }
1306
1307 #[test]
1308 fn world_storage_paths_stay_relative_to_the_save_root() {
1309 let absolute = temp_dir().display().to_string();
1310 assert_eq!(
1311 validate_relative_path(&absolute, "storage.config.path"),
1312 Err("storage.config.path must be relative".to_owned())
1313 );
1314 assert_eq!(
1315 validate_relative_path("region", "storage.config.path"),
1316 Ok(())
1317 );
1318 }
1319}