Skip to main content

steel_core/
config.rs

1//! Server configuration types used at runtime.
2//!
3//! The full deserialization struct lives in the `steel` crate. Steel-core only
4//! defines `RuntimeConfig` (the subset kept after startup) and the world/domain
5//! configuration types that both crates share.
6
7use 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
25/// Error returned when online mode is configured without its authentication handshake.
26pub const ONLINE_MODE_REQUIRES_ENCRYPTION: &str =
27    "encryption must be true when online_mode is enabled";
28
29/// Vanilla chat and command spam threshold window, in seconds.
30pub const DEFAULT_SPAM_THRESHOLD_SECONDS: i32 = 10;
31
32/// Default cap on queued neighbor-update tasks in one chained run.
33pub const DEFAULT_MAX_CHAINED_NEIGHBOR_UPDATES: i32 = 1_000_000;
34
35/// Validates the login settings that establish a player's authenticated identity.
36///
37/// # Errors
38///
39/// Returns an error when online mode could accept a client-supplied UUID without
40/// completing the encryption-backed `hasJoined` flow.
41pub 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/// Runtime server configuration — the subset of settings needed after startup.
53///
54/// Stored on `Server` and accessed by game logic at runtime.
55#[derive(Debug, Clone)]
56pub struct RuntimeConfig {
57    /// The maximum number of players that can be on the server at once.
58    pub max_players: u32,
59    /// The view distance of the server.
60    pub view_distance: u8,
61    /// The simulation distance of the server.
62    pub simulation_distance: u8,
63    /// Maximum queued neighbor-update tasks in one chained run; negative means unlimited.
64    pub max_chained_neighbor_updates: i32,
65    /// Whether the server is in online mode.
66    pub online_mode: bool,
67    /// Optional authentication endpoint for online-mode `hasJoined` checks.
68    pub auth_server: Option<String>,
69    /// Optional endpoint for online-mode player name-to-profile lookups.
70    pub profile_server: Option<String>,
71    /// Optional endpoint for Mojang-compatible service public keys.
72    pub services_server: Option<String>,
73    /// Whether the server should use encryption. Required in online mode.
74    pub encryption: bool,
75    /// Whether vanilla floating/flying movement checks permit unauthorized flight.
76    pub allow_flight: bool,
77    /// The message of the day.
78    pub motd: String,
79    /// Whether to use a favicon.
80    pub use_favicon: bool,
81    /// The path to the favicon.
82    pub favicon: String,
83    /// Whether to enforce secure chat.
84    pub enforce_secure_chat: bool,
85    /// Vanilla chat spam threshold window in seconds
86    pub chat_spam_threshold_seconds: i32,
87    /// Vanilla command spam threshold window in seconds
88    pub command_spam_threshold_seconds: i32,
89    /// The compression settings for the server.
90    pub compression: Option<CompressionInfo>,
91    /// All settings and configurations for server links.
92    pub server_links: Option<ServerLinks>,
93    /// Optional count of persistent inter-tick gameplay packet workers.
94    pub packet_workers: Option<usize>,
95    /// Optional worker count for the Rayon chunk generation pool.
96    pub chunk_generation_threads: Option<usize>,
97    /// Optional worker count for the Rayon chunk encoding pool.
98    pub chunk_encoding_threads: Option<usize>,
99}
100
101impl Default for RuntimeConfig {
102    /// The settings a freshly packaged server ships, leaving the optional service
103    /// endpoints and server links unset.
104    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    /// Builds the `CServerLinks` packet from config, if server links are enabled.
133    #[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/// Label type for server links — either built-in string or custom `TextComponent`.
158#[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    /// Built-in server link type (e.g., "`bug_report`", "website")
166    BuiltIn(ServerLinksType),
167    /// Custom text component with formatting
168    Custom(TextComponent),
169}
170
171/// A single server link configuration entry.
172#[derive(Debug, Clone, Deserialize)]
173pub struct ConfigLink {
174    /// The label for this link (built-in type or custom `TextComponent`)
175    pub label: ConfigLabel,
176    /// The URL for this link
177    pub url: String,
178}
179
180/// Server links configuration.
181#[derive(Debug, Clone, Deserialize, Default)]
182#[serde(default)]
183pub struct ServerLinks {
184    /// Enable the server links feature
185    pub enable: bool,
186    /// List of server links to display
187    #[serde(default)]
188    pub links: Vec<ConfigLink>,
189}
190
191/// Configuration for world storage.
192#[derive(Debug, Clone)]
193pub enum WorldStorageConfig {
194    /// Standard disk persistence using region files.
195    Disk {
196        /// Path to the world directory (e.g., "world/overworld").
197        path: String,
198    },
199    /// RAM-only storage with empty chunks created on demand.
200    /// No data is persisted — useful for testing and minigames.
201    RamOnly,
202}
203
204/// Parsed `worlds.toml` root.
205#[derive(Debug, Clone, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct WorldsConfig {
208    /// Root directory for save data.
209    #[serde(default = "default_save_path")]
210    pub save_path: String,
211    /// Root seed default. Empty or omitted means random.
212    #[serde(default)]
213    pub seed: Option<String>,
214    /// Root default game mode for first-visit player data.
215    #[serde(default, deserialize_with = "deserialize_optional_game_type")]
216    pub default_gamemode: Option<GameType>,
217    /// Root default difficulty for new level data.
218    #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
219    pub difficulty: Option<Difficulty>,
220    /// Root world storage default.
221    #[serde(default)]
222    pub storage: Option<StorageSelection>,
223    /// Global player data storage selection.
224    #[serde(default)]
225    pub player_storage: Option<StorageSelection>,
226    /// Domain declarations keyed by domain name.
227    pub domains: BTreeMap<String, DomainConfig>,
228}
229
230/// Parsed domain config inside `worlds.toml`.
231#[derive(Debug, Clone, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct DomainConfig {
234    /// Whether this is the server's default domain.
235    #[serde(default)]
236    pub default: bool,
237    /// Domain seed override. Empty means random for this domain.
238    #[serde(default)]
239    pub seed: Option<String>,
240    /// Domain default game mode override.
241    #[serde(default, deserialize_with = "deserialize_optional_game_type")]
242    pub default_gamemode: Option<GameType>,
243    /// Domain difficulty override for new level data.
244    #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
245    pub difficulty: Option<Difficulty>,
246    /// Domain storage override.
247    #[serde(default)]
248    pub storage: Option<StorageSelection>,
249    /// Worlds declared in this domain.
250    #[serde(default)]
251    pub worlds: Vec<WorldEntryConfig>,
252}
253
254/// Parsed world entry inside a domain.
255#[derive(Debug, Clone, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct WorldEntryConfig {
258    /// Path part of the loaded world identifier. The domain table key supplies the namespace.
259    pub name: String,
260    /// Generator factory identifier.
261    pub generator: Identifier,
262    /// Whether this is the domain's default world.
263    #[serde(default)]
264    pub default: bool,
265    /// World seed override. Empty means random for this world.
266    #[serde(default)]
267    pub seed: Option<String>,
268    /// World default game mode override.
269    #[serde(default, deserialize_with = "deserialize_optional_game_type")]
270    pub default_gamemode: Option<GameType>,
271    /// World difficulty override for new level data.
272    #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
273    pub difficulty: Option<Difficulty>,
274    /// World storage override.
275    #[serde(default)]
276    pub storage: Option<StorageSelection>,
277    /// Same-domain world name used by Nether portals from this world.
278    #[serde(default)]
279    pub nether_portal_target: Option<String>,
280    /// Same-domain world name used by End portals from non-End worlds.
281    #[serde(default)]
282    pub end_portal_target: Option<String>,
283    /// Generator-specific config. The selected generator validates this strictly.
284    #[serde(default)]
285    pub config: Option<toml::Value>,
286}
287
288/// Registry-backed storage selection from config.
289#[derive(Debug, Clone, Deserialize)]
290#[serde(deny_unknown_fields)]
291pub struct StorageSelection {
292    /// Storage backend identifier.
293    #[serde(rename = "type")]
294    pub kind: Identifier,
295    /// Storage-specific config. The selected storage backend validates this strictly.
296    #[serde(default)]
297    pub config: Option<toml::Value>,
298}
299
300impl StorageSelection {
301    /// Default disk world storage.
302    #[must_use]
303    pub fn default_world_disk() -> Self {
304        Self {
305            kind: Identifier::from_steel("disk"),
306            config: None,
307        }
308    }
309
310    /// Default file-backed player storage.
311    #[must_use]
312    pub fn default_player_file() -> Self {
313        Self {
314            kind: Identifier::from_steel("file"),
315            config: None,
316        }
317    }
318
319    /// Returns the config table or an empty table if omitted.
320    #[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/// Fully validated and cascaded worlds config.
329#[derive(Debug, Clone)]
330pub struct ResolvedWorldsConfig {
331    /// Root directory for save data.
332    pub save_path: PathBuf,
333    /// Default domain name.
334    pub default_domain: String,
335    /// Resolved domain configs.
336    pub domains: Vec<ResolvedDomainConfig>,
337    /// Resolved world configs in startup creation order.
338    pub worlds: Vec<ResolvedWorldConfig>,
339    /// Player data storage selection.
340    pub player_storage: StorageSelection,
341}
342
343/// Validated domain config.
344#[derive(Debug, Clone)]
345pub struct ResolvedDomainConfig {
346    /// Domain name.
347    pub name: String,
348    /// Default world identifier in this domain.
349    pub default_world: Identifier,
350    /// World identifiers in this domain.
351    pub worlds: Vec<Identifier>,
352}
353
354/// Validated world startup config.
355#[derive(Debug, Clone)]
356pub struct ResolvedWorldConfig {
357    /// Loaded world identifier (`domain:world`).
358    pub key: Identifier,
359    /// Domain name.
360    pub domain: String,
361    /// World path/name inside the domain.
362    pub name: String,
363    /// Generator factory identifier.
364    pub generator: Identifier,
365    /// Strictly validated generator config.
366    pub generator_config: ValidatedWorldGeneratorConfig,
367    /// Resolved world seed.
368    pub seed: i64,
369    /// Default game mode for first-visit player data in this world.
370    pub default_gamemode: GameType,
371    /// Difficulty for new level data in this world.
372    pub difficulty: Difficulty,
373    /// Resolved world storage selection.
374    pub storage: StorageSelection,
375    /// Explicit same-domain Nether portal target, if configured.
376    pub nether_portal_target: Option<Identifier>,
377    /// Explicit same-domain End portal entry target, if configured.
378    pub end_portal_target: Option<Identifier>,
379}
380
381impl WorldsConfig {
382    /// Validates `worlds.toml` and resolves cascaded defaults.
383    ///
384    /// # Errors
385    /// Returns a human-readable startup error if any invariant is violated.
386    pub fn validate_and_resolve(
387        &self,
388        generator_registry: &WorldGeneratorRegistry,
389        storage_registry: &WorldStorageRegistry,
390    ) -> Result<ResolvedWorldsConfig, String> {
391        // Stays on validate_clean_path: an embedded server cannot chdir per world, so its
392        // save root may be absolute.
393        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
748/// Validates a config path, rejecting `.` and `..` components.
749///
750/// Absolute paths are accepted. Use [`validate_relative_path`] for paths that have to stay
751/// under a Steel-owned root.
752pub 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
769/// Validates a config path as a relative path under a Steel-owned root.
770pub 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}