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 fn validate_login_security(
36 online_mode: bool,
37 encryption: bool,
38) -> Result<(), &'static str> {
39 if online_mode && !encryption {
40 Err(ONLINE_MODE_REQUIRES_ENCRYPTION)
41 } else {
42 Ok(())
43 }
44}
45
46#[derive(Debug, Clone)]
50pub struct RuntimeConfig {
51 pub max_players: u32,
53 pub view_distance: u8,
55 pub simulation_distance: u8,
57 pub max_chained_neighbor_updates: i32,
59 pub online_mode: bool,
61 pub auth_server: Option<String>,
63 pub profile_server: Option<String>,
65 pub encryption: bool,
67 pub allow_flight: bool,
69 pub motd: String,
71 pub use_favicon: bool,
73 pub favicon: String,
75 pub enforce_secure_chat: bool,
77 pub chat_spam_threshold_seconds: i32,
79 pub command_spam_threshold_seconds: i32,
81 pub compression: Option<CompressionInfo>,
83 pub server_links: Option<ServerLinks>,
85 pub packet_workers: Option<usize>,
87 pub chunk_generation_threads: Option<usize>,
89 pub chunk_encoding_threads: Option<usize>,
91}
92
93impl RuntimeConfig {
94 #[must_use]
96 pub fn server_links_packet(&self) -> Option<CServerLinks> {
97 let server_links = self.server_links.as_ref()?;
98
99 if !server_links.enable || server_links.links.is_empty() {
100 return None;
101 }
102
103 let links: Vec<Link> = server_links
104 .links
105 .iter()
106 .map(|config_link| {
107 let label = match &config_link.label {
108 ConfigLabel::BuiltIn(link_type) => Or::Left(*link_type),
109 ConfigLabel::Custom(text_component) => Or::Right(text_component.clone()),
110 };
111 Link::new(label, config_link.url.clone())
112 })
113 .collect();
114
115 Some(CServerLinks { links })
116 }
117}
118
119#[derive(Debug, Clone, Deserialize)]
121#[serde(untagged)]
122#[expect(
123 clippy::large_enum_variant,
124 reason = "TextComponent variant is common; boxing would add indirection for every use"
125)]
126pub enum ConfigLabel {
127 BuiltIn(ServerLinksType),
129 Custom(TextComponent),
131}
132
133#[derive(Debug, Clone, Deserialize)]
135pub struct ConfigLink {
136 pub label: ConfigLabel,
138 pub url: String,
140}
141
142#[derive(Debug, Clone, Deserialize, Default)]
144#[serde(default)]
145pub struct ServerLinks {
146 pub enable: bool,
148 #[serde(default)]
150 pub links: Vec<ConfigLink>,
151}
152
153#[derive(Debug, Clone)]
155pub enum WorldStorageConfig {
156 Disk {
158 path: String,
160 },
161 RamOnly,
164}
165
166#[derive(Debug, Clone, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct WorldsConfig {
170 #[serde(default = "default_save_path")]
172 pub save_path: String,
173 #[serde(default)]
175 pub seed: Option<String>,
176 #[serde(default, deserialize_with = "deserialize_optional_game_type")]
178 pub default_gamemode: Option<GameType>,
179 #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
181 pub difficulty: Option<Difficulty>,
182 #[serde(default)]
184 pub storage: Option<StorageSelection>,
185 #[serde(default)]
187 pub player_storage: Option<StorageSelection>,
188 pub domains: BTreeMap<String, DomainConfig>,
190}
191
192#[derive(Debug, Clone, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct DomainConfig {
196 #[serde(default)]
198 pub default: bool,
199 #[serde(default)]
201 pub seed: Option<String>,
202 #[serde(default, deserialize_with = "deserialize_optional_game_type")]
204 pub default_gamemode: Option<GameType>,
205 #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
207 pub difficulty: Option<Difficulty>,
208 #[serde(default)]
210 pub storage: Option<StorageSelection>,
211 #[serde(default)]
213 pub worlds: Vec<WorldEntryConfig>,
214}
215
216#[derive(Debug, Clone, Deserialize)]
218#[serde(deny_unknown_fields)]
219pub struct WorldEntryConfig {
220 pub name: String,
222 pub generator: Identifier,
224 #[serde(default)]
226 pub default: bool,
227 #[serde(default)]
229 pub seed: Option<String>,
230 #[serde(default, deserialize_with = "deserialize_optional_game_type")]
232 pub default_gamemode: Option<GameType>,
233 #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
235 pub difficulty: Option<Difficulty>,
236 #[serde(default)]
238 pub storage: Option<StorageSelection>,
239 #[serde(default)]
241 pub nether_portal_target: Option<String>,
242 #[serde(default)]
244 pub end_portal_target: Option<String>,
245 #[serde(default)]
247 pub config: Option<toml::Value>,
248}
249
250#[derive(Debug, Clone, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct StorageSelection {
254 #[serde(rename = "type")]
256 pub kind: Identifier,
257 #[serde(default)]
259 pub config: Option<toml::Value>,
260}
261
262impl StorageSelection {
263 #[must_use]
265 pub fn default_world_disk() -> Self {
266 Self {
267 kind: Identifier::from_steel("disk"),
268 config: None,
269 }
270 }
271
272 #[must_use]
274 pub fn default_player_file() -> Self {
275 Self {
276 kind: Identifier::from_steel("file"),
277 config: None,
278 }
279 }
280
281 #[must_use]
283 pub fn config_value(&self) -> toml::Value {
284 self.config
285 .clone()
286 .unwrap_or_else(|| toml::Value::Table(Map::new()))
287 }
288}
289
290#[derive(Debug, Clone)]
292pub struct ResolvedWorldsConfig {
293 pub save_path: PathBuf,
295 pub default_domain: String,
297 pub domains: Vec<ResolvedDomainConfig>,
299 pub worlds: Vec<ResolvedWorldConfig>,
301 pub player_storage: StorageSelection,
303}
304
305#[derive(Debug, Clone)]
307pub struct ResolvedDomainConfig {
308 pub name: String,
310 pub default_world: Identifier,
312 pub worlds: Vec<Identifier>,
314}
315
316#[derive(Debug, Clone)]
318pub struct ResolvedWorldConfig {
319 pub key: Identifier,
321 pub domain: String,
323 pub name: String,
325 pub generator: Identifier,
327 pub generator_config: ValidatedWorldGeneratorConfig,
329 pub seed: i64,
331 pub default_gamemode: GameType,
333 pub difficulty: Difficulty,
335 pub storage: StorageSelection,
337 pub nether_portal_target: Option<Identifier>,
339 pub end_portal_target: Option<Identifier>,
341}
342
343impl WorldsConfig {
344 pub fn validate_and_resolve(
349 &self,
350 generator_registry: &WorldGeneratorRegistry,
351 storage_registry: &WorldStorageRegistry,
352 ) -> Result<ResolvedWorldsConfig, String> {
353 validate_relative_path(&self.save_path, "save_path")?;
354
355 if self.domains.is_empty() {
356 return Err("worlds.toml must declare at least one domain".to_owned());
357 }
358
359 let root_defaults = RootWorldDefaults {
360 seed: seed_from_config(self.seed.as_deref().unwrap_or("")),
361 gamemode: self.default_gamemode.unwrap_or(GameType::Survival),
362 difficulty: self.difficulty.unwrap_or(Difficulty::Normal),
363 };
364 let root_storage = self
365 .storage
366 .clone()
367 .unwrap_or_else(StorageSelection::default_world_disk);
368 storage_registry.validate_selection(&root_storage)?;
369
370 let player_storage = self
371 .player_storage
372 .clone()
373 .unwrap_or_else(StorageSelection::default_player_file);
374 validate_player_storage_selection(&player_storage)?;
375
376 let mut default_domain = None;
377 let mut resolved_domains = Vec::with_capacity(self.domains.len());
378 let mut resolved_worlds = Vec::new();
379
380 for (domain_name, domain) in &self.domains {
381 if domain.default && default_domain.replace(domain_name.clone()).is_some() {
382 return Err("worlds.toml must declare exactly one default domain".to_owned());
383 }
384 let (resolved_domain, mut domain_worlds) = resolve_domain_config(
385 domain_name,
386 domain,
387 root_defaults,
388 &root_storage,
389 generator_registry,
390 storage_registry,
391 )?;
392 resolved_domains.push(resolved_domain);
393 resolved_worlds.append(&mut domain_worlds);
394 }
395
396 if resolved_worlds.is_empty() {
397 return Err("worlds.toml must declare at least one world".to_owned());
398 }
399
400 let Some(default_domain) = default_domain else {
401 return Err("worlds.toml must declare exactly one default domain".to_owned());
402 };
403
404 Ok(ResolvedWorldsConfig {
405 save_path: PathBuf::from(&self.save_path),
406 default_domain,
407 domains: resolved_domains,
408 worlds: resolved_worlds,
409 player_storage,
410 })
411 }
412}
413
414fn resolve_domain_config(
415 domain_name: &str,
416 domain: &DomainConfig,
417 root_defaults: RootWorldDefaults,
418 root_storage: &StorageSelection,
419 generator_registry: &WorldGeneratorRegistry,
420 storage_registry: &WorldStorageRegistry,
421) -> Result<(ResolvedDomainConfig, Vec<ResolvedWorldConfig>), String> {
422 validate_domain_name(domain_name)?;
423 if domain.worlds.is_empty() {
424 return Err(format!(
425 "domain {domain_name} must declare at least one world"
426 ));
427 }
428
429 let domain_defaults = DomainWorldDefaults::from_config(domain, root_defaults);
430 let domain_storage = domain
431 .storage
432 .clone()
433 .unwrap_or_else(|| root_storage.clone());
434 storage_registry.validate_selection(&domain_storage)?;
435
436 let mut seen_world_names = FxHashSet::default();
437 let mut default_world = None;
438 let mut domain_world_ids = Vec::with_capacity(domain.worlds.len());
439 let mut resolved_worlds = Vec::with_capacity(domain.worlds.len());
440
441 for world in &domain.worlds {
442 let resolved_world = resolve_world_config(
443 domain_name,
444 world,
445 domain_defaults,
446 &domain_storage,
447 generator_registry,
448 storage_registry,
449 )?;
450
451 if !seen_world_names.insert(world.name.clone()) {
452 return Err(format!(
453 "domain {domain_name} declares duplicate world {}",
454 world.name
455 ));
456 }
457 if world.default && default_world.replace(resolved_world.key.clone()).is_some() {
458 return Err(format!(
459 "domain {domain_name} must declare exactly one default world"
460 ));
461 }
462
463 domain_world_ids.push(resolved_world.key.clone());
464 resolved_worlds.push(resolved_world);
465 }
466
467 validate_explicit_portal_targets(domain_name, &resolved_worlds, &seen_world_names)?;
468
469 let Some(default_world) = default_world else {
470 return Err(format!(
471 "domain {domain_name} must declare exactly one default world"
472 ));
473 };
474
475 Ok((
476 ResolvedDomainConfig {
477 name: domain_name.to_owned(),
478 default_world,
479 worlds: domain_world_ids,
480 },
481 resolved_worlds,
482 ))
483}
484
485fn resolve_world_config(
486 domain_name: &str,
487 world: &WorldEntryConfig,
488 domain_defaults: DomainWorldDefaults,
489 domain_storage: &StorageSelection,
490 generator_registry: &WorldGeneratorRegistry,
491 storage_registry: &WorldStorageRegistry,
492) -> Result<ResolvedWorldConfig, String> {
493 validate_world_name(&world.name, domain_name)?;
494 let world_key = Identifier::new(domain_name.to_owned(), world.name.clone());
495
496 let raw_generator_config = world
497 .config
498 .clone()
499 .unwrap_or_else(|| toml::Value::Table(Map::new()));
500 let generator_config =
501 generator_registry.validate_config(&world.generator, &raw_generator_config)?;
502
503 let storage = world
504 .storage
505 .clone()
506 .unwrap_or_else(|| domain_storage.clone());
507 storage_registry.validate_selection(&storage)?;
508 let nether_portal_target = resolve_explicit_portal_target(
509 domain_name,
510 &world.name,
511 world.nether_portal_target.as_deref(),
512 "nether_portal_target",
513 )?;
514 let end_portal_target = resolve_explicit_portal_target(
515 domain_name,
516 &world.name,
517 world.end_portal_target.as_deref(),
518 "end_portal_target",
519 )?;
520 validate_end_portal_target_dimension(
521 domain_name,
522 &world.name,
523 end_portal_target.as_ref(),
524 &generator_config,
525 )?;
526
527 Ok(ResolvedWorldConfig {
528 key: world_key,
529 domain: domain_name.to_owned(),
530 name: world.name.clone(),
531 generator: world.generator.clone(),
532 generator_config,
533 seed: world
534 .seed
535 .as_deref()
536 .map_or(domain_defaults.seed, seed_from_config),
537 default_gamemode: world.default_gamemode.unwrap_or(domain_defaults.gamemode),
538 difficulty: world.difficulty.unwrap_or(domain_defaults.difficulty),
539 storage,
540 nether_portal_target,
541 end_portal_target,
542 })
543}
544
545fn resolve_explicit_portal_target(
546 domain_name: &str,
547 source_world_name: &str,
548 target_world_name: Option<&str>,
549 field: &str,
550) -> Result<Option<Identifier>, String> {
551 let Some(target_world_name) = target_world_name else {
552 return Ok(None);
553 };
554 if target_world_name.is_empty()
555 || target_world_name.contains('/')
556 || !Identifier::validate_path(target_world_name)
557 {
558 return Err(format!(
559 "invalid {field} {target_world_name} for world {domain_name}:{source_world_name}"
560 ));
561 }
562 if target_world_name == source_world_name {
563 return Err(format!(
564 "world {domain_name}:{source_world_name} {field} must not target itself"
565 ));
566 }
567
568 Ok(Some(Identifier::new(
569 domain_name.to_owned(),
570 target_world_name.to_owned(),
571 )))
572}
573
574fn validate_end_portal_target_dimension(
575 domain_name: &str,
576 source_world_name: &str,
577 end_portal_target: Option<&Identifier>,
578 generator_config: &ValidatedWorldGeneratorConfig,
579) -> Result<(), String> {
580 if end_portal_target.is_some()
581 && generator_config.dimension_type() == &vanilla_dimension_types::THE_END
582 {
583 return Err(format!(
584 "world {domain_name}:{source_world_name} end_portal_target is invalid on End-dimension worlds; End portal returns use respawn data"
585 ));
586 }
587
588 Ok(())
589}
590
591fn validate_explicit_portal_targets(
592 domain_name: &str,
593 worlds: &[ResolvedWorldConfig],
594 world_names: &FxHashSet<String>,
595) -> Result<(), String> {
596 let worlds_by_name = worlds
597 .iter()
598 .map(|world| (world.name.as_str(), world))
599 .collect::<FxHashMap<_, _>>();
600 for world in worlds {
601 validate_explicit_portal_target(
602 domain_name,
603 &world.key,
604 world.nether_portal_target.as_ref(),
605 "nether_portal_target",
606 world_names,
607 )?;
608 validate_explicit_portal_target(
609 domain_name,
610 &world.key,
611 world.end_portal_target.as_ref(),
612 "end_portal_target",
613 world_names,
614 )?;
615 validate_end_portal_target_target_dimension(domain_name, world, &worlds_by_name)?;
616 }
617
618 Ok(())
619}
620
621fn validate_explicit_portal_target(
622 domain_name: &str,
623 source_world: &Identifier,
624 target: Option<&Identifier>,
625 field: &str,
626 world_names: &FxHashSet<String>,
627) -> Result<(), String> {
628 let Some(target) = target else {
629 return Ok(());
630 };
631 if world_names.contains(target.path.as_ref()) {
632 return Ok(());
633 }
634
635 Err(format!(
636 "world {source_world} {field} target {} is not declared in domain {domain_name}",
637 target.path
638 ))
639}
640
641fn validate_end_portal_target_target_dimension(
642 domain_name: &str,
643 source_world: &ResolvedWorldConfig,
644 worlds_by_name: &FxHashMap<&str, &ResolvedWorldConfig>,
645) -> Result<(), String> {
646 let Some(target) = &source_world.end_portal_target else {
647 return Ok(());
648 };
649 let Some(target_world) = worlds_by_name.get(target.path.as_ref()) else {
650 return Ok(());
651 };
652
653 if target_world.generator_config.dimension_type() == &vanilla_dimension_types::THE_END {
654 return Ok(());
655 }
656
657 Err(format!(
658 "world {domain_name}:{} end_portal_target {} must target an End-dimension world",
659 source_world.name, target.path
660 ))
661}
662
663#[derive(Clone, Copy)]
664struct RootWorldDefaults {
665 seed: i64,
666 gamemode: GameType,
667 difficulty: Difficulty,
668}
669
670#[derive(Clone, Copy)]
671struct DomainWorldDefaults {
672 seed: i64,
673 gamemode: GameType,
674 difficulty: Difficulty,
675}
676
677impl DomainWorldDefaults {
678 fn from_config(domain: &DomainConfig, root: RootWorldDefaults) -> Self {
679 Self {
680 seed: domain.seed.as_deref().map_or(root.seed, seed_from_config),
681 gamemode: domain.default_gamemode.unwrap_or(root.gamemode),
682 difficulty: domain.difficulty.unwrap_or(root.difficulty),
683 }
684 }
685}
686
687fn default_save_path() -> String {
688 "saves".to_owned()
689}
690
691fn validate_domain_name(name: &str) -> Result<(), String> {
692 if name == "global" {
693 return Err("domain name global is reserved".to_owned());
694 }
695 if name.is_empty() || !Identifier::validate_namespace(name) {
696 return Err(format!("invalid domain name {name}"));
697 }
698 Ok(())
699}
700
701fn validate_world_name(name: &str, domain: &str) -> Result<(), String> {
702 if name.is_empty() || name.contains('/') || !Identifier::validate_path(name) {
703 return Err(format!("invalid world name {name} in domain {domain}"));
704 }
705 Ok(())
706}
707
708pub fn validate_relative_path(path: &str, field: &str) -> Result<(), String> {
710 let path = Path::new(path);
711 if path.as_os_str().is_empty() {
712 return Err(format!("{field} must not be empty"));
713 }
714 if path.is_absolute() {
715 return Err(format!("{field} must be relative"));
716 }
717 for component in path.components() {
718 match component {
719 Component::Normal(_) => {}
720 Component::CurDir
721 | Component::ParentDir
722 | Component::RootDir
723 | Component::Prefix(_) => {
724 return Err(format!("{field} must be a clean relative path"));
725 }
726 }
727 }
728 Ok(())
729}
730
731fn validate_player_storage_selection(selection: &StorageSelection) -> Result<(), String> {
732 if selection.kind != Identifier::from_steel("file") {
733 return Err(format!("unknown player storage {}", selection.kind));
734 }
735 if selection.config.is_some() {
736 return Err("steel:file player storage does not accept config yet".to_owned());
737 }
738 Ok(())
739}
740
741fn seed_from_config(seed: &str) -> i64 {
742 if seed.is_empty() {
743 rand::random()
744 } else {
745 seed.parse().unwrap_or_else(|_| {
746 let mut hash: i64 = 0;
747 for byte in seed.bytes() {
748 hash = hash.wrapping_mul(31).wrapping_add(i64::from(byte));
749 }
750 hash
751 })
752 }
753}
754
755fn deserialize_optional_game_type<'de, D>(deserializer: D) -> Result<Option<GameType>, D::Error>
756where
757 D: Deserializer<'de>,
758{
759 let Some(value) = Option::<String>::deserialize(deserializer)? else {
760 return Ok(None);
761 };
762 parse_game_type(&value).map(Some).map_err(DeError::custom)
763}
764
765fn parse_game_type(value: &str) -> Result<GameType, String> {
766 match value {
767 "survival" => Ok(GameType::Survival),
768 "creative" => Ok(GameType::Creative),
769 "adventure" => Ok(GameType::Adventure),
770 "spectator" => Ok(GameType::Spectator),
771 _ => Err(format!("invalid gamemode {value}")),
772 }
773}
774
775fn deserialize_optional_difficulty<'de, D>(deserializer: D) -> Result<Option<Difficulty>, D::Error>
776where
777 D: Deserializer<'de>,
778{
779 let Some(value) = Option::<String>::deserialize(deserializer)? else {
780 return Ok(None);
781 };
782 parse_difficulty(&value).map(Some).map_err(DeError::custom)
783}
784
785fn parse_difficulty(value: &str) -> Result<Difficulty, String> {
786 match value {
787 "peaceful" => Ok(Difficulty::Peaceful),
788 "easy" => Ok(Difficulty::Easy),
789 "normal" => Ok(Difficulty::Normal),
790 "hard" => Ok(Difficulty::Hard),
791 _ => Err(format!("invalid difficulty {value}")),
792 }
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use steel_registry::init_vanilla_registry;
799
800 #[test]
801 fn online_mode_requires_the_authenticated_encryption_flow() {
802 assert_eq!(validate_login_security(true, true), Ok(()));
803 assert_eq!(
804 validate_login_security(true, false),
805 Err(ONLINE_MODE_REQUIRES_ENCRYPTION)
806 );
807 assert_eq!(validate_login_security(false, true), Ok(()));
808 assert_eq!(validate_login_security(false, false), Ok(()));
809 }
810
811 fn registries() -> (WorldGeneratorRegistry, WorldStorageRegistry) {
812 init_vanilla_registry();
813 let generators = WorldGeneratorRegistry::new_with_builtins()
814 .expect("built-in generator registry should initialize");
815 let storage = WorldStorageRegistry::new_with_builtins()
816 .expect("built-in storage registry should initialize");
817 (generators, storage)
818 }
819
820 fn resolve(input: &str) -> Result<ResolvedWorldsConfig, String> {
821 let config: WorldsConfig = toml::from_str(input).expect("worlds config should parse");
822 let (generators, storage) = registries();
823 config.validate_and_resolve(&generators, &storage)
824 }
825
826 #[test]
827 fn resolves_cascaded_world_defaults() {
828 let resolved = resolve(
829 r#"
830save_path = "saves"
831seed = "1"
832default_gamemode = "survival"
833difficulty = "normal"
834
835[storage]
836type = "steel:disk"
837
838[domains.minecraft]
839default = true
840seed = "2"
841default_gamemode = "adventure"
842difficulty = "peaceful"
843
844[[domains.minecraft.worlds]]
845name = "overworld"
846generator = "minecraft:overworld"
847default = true
848
849[[domains.minecraft.worlds]]
850name = "the_nether"
851generator = "minecraft:the_nether"
852seed = "3"
853default_gamemode = "creative"
854difficulty = "hard"
855"#,
856 )
857 .expect("valid worlds config should resolve");
858
859 assert_eq!(resolved.default_domain, "minecraft");
860 assert_eq!(resolved.worlds.len(), 2);
861 let overworld = resolved
862 .worlds
863 .iter()
864 .find(|world| world.name == "overworld")
865 .expect("overworld should exist");
866 assert_eq!(overworld.seed, 2);
867 assert_eq!(overworld.default_gamemode, GameType::Adventure);
868 assert_eq!(overworld.difficulty, Difficulty::Peaceful);
869
870 let nether = resolved
871 .worlds
872 .iter()
873 .find(|world| world.name == "the_nether")
874 .expect("nether should exist");
875 assert_eq!(nether.seed, 3);
876 assert_eq!(nether.default_gamemode, GameType::Creative);
877 assert_eq!(nether.difficulty, Difficulty::Hard);
878 }
879
880 #[test]
881 fn resolves_explicit_portal_targets() {
882 let resolved = resolve(
883 r#"
884[domains.minecraft]
885default = true
886
887[[domains.minecraft.worlds]]
888name = "overworld2"
889generator = "minecraft:overworld"
890default = true
891nether_portal_target = "the_nether2"
892end_portal_target = "the_end2"
893
894[[domains.minecraft.worlds]]
895name = "the_nether2"
896generator = "minecraft:the_nether"
897nether_portal_target = "overworld2"
898
899[[domains.minecraft.worlds]]
900name = "the_end2"
901generator = "minecraft:the_end"
902"#,
903 )
904 .expect("explicit portal targets should resolve");
905
906 let overworld = resolved
907 .worlds
908 .iter()
909 .find(|world| world.name == "overworld2")
910 .expect("overworld2 should exist");
911 assert_eq!(
912 overworld.nether_portal_target,
913 Some(Identifier::new("minecraft", "the_nether2"))
914 );
915 assert_eq!(
916 overworld.end_portal_target,
917 Some(Identifier::new("minecraft", "the_end2"))
918 );
919
920 let nether = resolved
921 .worlds
922 .iter()
923 .find(|world| world.name == "the_nether2")
924 .expect("the_nether2 should exist");
925 assert_eq!(
926 nether.nether_portal_target,
927 Some(Identifier::new("minecraft", "overworld2"))
928 );
929 assert_eq!(nether.end_portal_target, None);
930 }
931
932 #[test]
933 fn rejects_missing_explicit_portal_target() {
934 let error = resolve(
935 r#"
936[domains.minecraft]
937default = true
938
939[[domains.minecraft.worlds]]
940name = "overworld"
941generator = "minecraft:overworld"
942default = true
943nether_portal_target = "missing"
944"#,
945 )
946 .expect_err("missing explicit portal target should be rejected");
947 assert!(error.contains("nether_portal_target"));
948 assert!(error.contains("missing"));
949 }
950
951 #[test]
952 fn rejects_self_explicit_portal_target() {
953 let error = resolve(
954 r#"
955[domains.minecraft]
956default = true
957
958[[domains.minecraft.worlds]]
959name = "overworld"
960generator = "minecraft:overworld"
961default = true
962end_portal_target = "overworld"
963"#,
964 )
965 .expect_err("self explicit portal target should be rejected");
966 assert!(error.contains("end_portal_target"));
967 assert!(error.contains("must not target itself"));
968 }
969
970 #[test]
971 fn rejects_end_portal_target_on_end_dimension_world() {
972 let error = resolve(
973 r#"
974[domains.minecraft]
975default = true
976
977[[domains.minecraft.worlds]]
978name = "overworld"
979generator = "minecraft:overworld"
980default = true
981
982[[domains.minecraft.worlds]]
983name = "the_end"
984generator = "minecraft:the_end"
985end_portal_target = "overworld"
986"#,
987 )
988 .expect_err("End-dimension world end_portal_target should be rejected");
989 assert!(error.contains("end_portal_target"));
990 assert!(error.contains("End-dimension worlds"));
991 }
992
993 #[test]
994 fn rejects_end_portal_target_on_flat_end_dimension_world() {
995 let error = resolve(
996 r#"
997[domains.minecraft]
998default = true
999
1000[[domains.minecraft.worlds]]
1001name = "overworld"
1002generator = "minecraft:overworld"
1003default = true
1004
1005[[domains.minecraft.worlds]]
1006name = "flat_end"
1007generator = "minecraft:flat"
1008end_portal_target = "overworld"
1009
1010[domains.minecraft.worlds.config]
1011dimension_type = "minecraft:the_end"
1012"#,
1013 )
1014 .expect_err("flat End-dimension world end_portal_target should be rejected");
1015 assert!(error.contains("end_portal_target"));
1016 assert!(error.contains("End-dimension worlds"));
1017 }
1018
1019 #[test]
1020 fn rejects_end_portal_target_to_non_end_dimension_world() {
1021 let error = resolve(
1022 r#"
1023[domains.minecraft]
1024default = true
1025
1026[[domains.minecraft.worlds]]
1027name = "overworld"
1028generator = "minecraft:overworld"
1029default = true
1030end_portal_target = "other_overworld"
1031
1032[[domains.minecraft.worlds]]
1033name = "other_overworld"
1034generator = "minecraft:overworld"
1035"#,
1036 )
1037 .expect_err("end_portal_target should require an End-dimension target");
1038 assert!(error.contains("end_portal_target"));
1039 assert!(error.contains("End-dimension world"));
1040 }
1041
1042 #[test]
1043 fn accepts_end_portal_target_to_flat_end_dimension_world() {
1044 let resolved = resolve(
1045 r#"
1046[domains.minecraft]
1047default = true
1048
1049[[domains.minecraft.worlds]]
1050name = "overworld"
1051generator = "minecraft:overworld"
1052default = true
1053end_portal_target = "flat_end"
1054
1055[[domains.minecraft.worlds]]
1056name = "flat_end"
1057generator = "minecraft:flat"
1058
1059[domains.minecraft.worlds.config]
1060dimension_type = "minecraft:the_end"
1061"#,
1062 )
1063 .expect("flat End-dimension end_portal_target should resolve");
1064 let overworld = resolved
1065 .worlds
1066 .iter()
1067 .find(|world| world.name == "overworld")
1068 .expect("overworld should exist");
1069 assert_eq!(
1070 overworld.end_portal_target,
1071 Some(Identifier::new("minecraft", "flat_end"))
1072 );
1073 }
1074
1075 #[test]
1076 fn rejects_cross_domain_explicit_portal_target() {
1077 let error = resolve(
1078 r#"
1079[domains.minecraft]
1080default = true
1081
1082[[domains.minecraft.worlds]]
1083name = "overworld"
1084generator = "minecraft:overworld"
1085default = true
1086nether_portal_target = "other:the_nether"
1087"#,
1088 )
1089 .expect_err("cross-domain-looking portal target should be rejected");
1090 assert!(error.contains("nether_portal_target"));
1091 }
1092
1093 #[test]
1094 fn rejects_reserved_global_domain() {
1095 let error = resolve(
1096 r#"
1097[domains.global]
1098default = true
1099
1100[[domains.global.worlds]]
1101name = "overworld"
1102generator = "minecraft:overworld"
1103default = true
1104"#,
1105 )
1106 .expect_err("global domain should be rejected");
1107 assert!(error.contains("reserved"));
1108 }
1109
1110 #[test]
1111 fn rejects_duplicate_world_names() {
1112 let error = resolve(
1113 r#"
1114[domains.minecraft]
1115default = true
1116
1117[[domains.minecraft.worlds]]
1118name = "overworld"
1119generator = "minecraft:overworld"
1120default = true
1121
1122[[domains.minecraft.worlds]]
1123name = "overworld"
1124generator = "minecraft:overworld"
1125"#,
1126 )
1127 .expect_err("duplicate world names should be rejected");
1128 assert!(error.contains("duplicate world"));
1129 }
1130
1131 #[test]
1132 fn rejects_missing_default_world() {
1133 let error = resolve(
1134 r#"
1135[domains.minecraft]
1136default = true
1137
1138[[domains.minecraft.worlds]]
1139name = "overworld"
1140generator = "minecraft:overworld"
1141"#,
1142 )
1143 .expect_err("missing default world should be rejected");
1144 assert!(error.contains("default world"));
1145 }
1146
1147 #[test]
1148 fn rejects_unknown_generator() {
1149 let error = resolve(
1150 r#"
1151[domains.minecraft]
1152default = true
1153
1154[[domains.minecraft.worlds]]
1155name = "overworld"
1156generator = "example:unknown"
1157default = true
1158"#,
1159 )
1160 .expect_err("unknown generator should be rejected");
1161 assert!(error.contains("unknown world generator"));
1162 }
1163
1164 #[test]
1165 fn flat_generator_config_is_optional() {
1166 let resolved = resolve(
1167 r#"
1168[domains.minecraft]
1169default = true
1170
1171[[domains.minecraft.worlds]]
1172name = "overworld"
1173generator = "minecraft:flat"
1174default = true
1175"#,
1176 )
1177 .expect("flat generator should default its config");
1178
1179 assert_eq!(
1180 resolved.worlds[0].generator,
1181 Identifier::vanilla_static("flat")
1182 );
1183 }
1184}