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/// Validates the login settings that establish a player's authenticated identity.
30///
31/// # Errors
32///
33/// Returns an error when online mode could accept a client-supplied UUID without
34/// completing the encryption-backed `hasJoined` flow.
35pub 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/// Runtime server configuration — the subset of settings needed after startup.
47///
48/// Stored on `Server` and accessed by game logic at runtime.
49#[derive(Debug, Clone)]
50pub struct RuntimeConfig {
51    /// The maximum number of players that can be on the server at once.
52    pub max_players: u32,
53    /// The view distance of the server.
54    pub view_distance: u8,
55    /// The simulation distance of the server.
56    pub simulation_distance: u8,
57    /// Maximum queued neighbor-update tasks in one chained run; negative means unlimited.
58    pub max_chained_neighbor_updates: i32,
59    /// Whether the server is in online mode.
60    pub online_mode: bool,
61    /// Optional authentication endpoint for online-mode `hasJoined` checks.
62    pub auth_server: Option<String>,
63    /// Optional endpoint for online-mode player name-to-profile lookups.
64    pub profile_server: Option<String>,
65    /// Whether the server should use encryption. Required in online mode.
66    pub encryption: bool,
67    /// Whether vanilla floating/flying movement checks permit unauthorized flight.
68    pub allow_flight: bool,
69    /// The message of the day.
70    pub motd: String,
71    /// Whether to use a favicon.
72    pub use_favicon: bool,
73    /// The path to the favicon.
74    pub favicon: String,
75    /// Whether to enforce secure chat.
76    pub enforce_secure_chat: bool,
77    /// Vanilla chat spam threshold window in seconds
78    pub chat_spam_threshold_seconds: i32,
79    /// Vanilla command spam threshold window in seconds
80    pub command_spam_threshold_seconds: i32,
81    /// The compression settings for the server.
82    pub compression: Option<CompressionInfo>,
83    /// All settings and configurations for server links.
84    pub server_links: Option<ServerLinks>,
85    /// Optional count of persistent inter-tick gameplay packet workers.
86    pub packet_workers: Option<usize>,
87    /// Optional worker count for the Rayon chunk generation pool.
88    pub chunk_generation_threads: Option<usize>,
89    /// Optional worker count for the Rayon chunk encoding pool.
90    pub chunk_encoding_threads: Option<usize>,
91}
92
93impl RuntimeConfig {
94    /// Builds the `CServerLinks` packet from config, if server links are enabled.
95    #[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/// Label type for server links — either built-in string or custom `TextComponent`.
120#[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    /// Built-in server link type (e.g., "`bug_report`", "website")
128    BuiltIn(ServerLinksType),
129    /// Custom text component with formatting
130    Custom(TextComponent),
131}
132
133/// A single server link configuration entry.
134#[derive(Debug, Clone, Deserialize)]
135pub struct ConfigLink {
136    /// The label for this link (built-in type or custom `TextComponent`)
137    pub label: ConfigLabel,
138    /// The URL for this link
139    pub url: String,
140}
141
142/// Server links configuration.
143#[derive(Debug, Clone, Deserialize, Default)]
144#[serde(default)]
145pub struct ServerLinks {
146    /// Enable the server links feature
147    pub enable: bool,
148    /// List of server links to display
149    #[serde(default)]
150    pub links: Vec<ConfigLink>,
151}
152
153/// Configuration for world storage.
154#[derive(Debug, Clone)]
155pub enum WorldStorageConfig {
156    /// Standard disk persistence using region files.
157    Disk {
158        /// Path to the world directory (e.g., "world/overworld").
159        path: String,
160    },
161    /// RAM-only storage with empty chunks created on demand.
162    /// No data is persisted — useful for testing and minigames.
163    RamOnly,
164}
165
166/// Parsed `worlds.toml` root.
167#[derive(Debug, Clone, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct WorldsConfig {
170    /// Root directory for save data.
171    #[serde(default = "default_save_path")]
172    pub save_path: String,
173    /// Root seed default. Empty or omitted means random.
174    #[serde(default)]
175    pub seed: Option<String>,
176    /// Root default game mode for first-visit player data.
177    #[serde(default, deserialize_with = "deserialize_optional_game_type")]
178    pub default_gamemode: Option<GameType>,
179    /// Root default difficulty for new level data.
180    #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
181    pub difficulty: Option<Difficulty>,
182    /// Root world storage default.
183    #[serde(default)]
184    pub storage: Option<StorageSelection>,
185    /// Global player data storage selection.
186    #[serde(default)]
187    pub player_storage: Option<StorageSelection>,
188    /// Domain declarations keyed by domain name.
189    pub domains: BTreeMap<String, DomainConfig>,
190}
191
192/// Parsed domain config inside `worlds.toml`.
193#[derive(Debug, Clone, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct DomainConfig {
196    /// Whether this is the server's default domain.
197    #[serde(default)]
198    pub default: bool,
199    /// Domain seed override. Empty means random for this domain.
200    #[serde(default)]
201    pub seed: Option<String>,
202    /// Domain default game mode override.
203    #[serde(default, deserialize_with = "deserialize_optional_game_type")]
204    pub default_gamemode: Option<GameType>,
205    /// Domain difficulty override for new level data.
206    #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
207    pub difficulty: Option<Difficulty>,
208    /// Domain storage override.
209    #[serde(default)]
210    pub storage: Option<StorageSelection>,
211    /// Worlds declared in this domain.
212    #[serde(default)]
213    pub worlds: Vec<WorldEntryConfig>,
214}
215
216/// Parsed world entry inside a domain.
217#[derive(Debug, Clone, Deserialize)]
218#[serde(deny_unknown_fields)]
219pub struct WorldEntryConfig {
220    /// Path part of the loaded world identifier. The domain table key supplies the namespace.
221    pub name: String,
222    /// Generator factory identifier.
223    pub generator: Identifier,
224    /// Whether this is the domain's default world.
225    #[serde(default)]
226    pub default: bool,
227    /// World seed override. Empty means random for this world.
228    #[serde(default)]
229    pub seed: Option<String>,
230    /// World default game mode override.
231    #[serde(default, deserialize_with = "deserialize_optional_game_type")]
232    pub default_gamemode: Option<GameType>,
233    /// World difficulty override for new level data.
234    #[serde(default, deserialize_with = "deserialize_optional_difficulty")]
235    pub difficulty: Option<Difficulty>,
236    /// World storage override.
237    #[serde(default)]
238    pub storage: Option<StorageSelection>,
239    /// Same-domain world name used by Nether portals from this world.
240    #[serde(default)]
241    pub nether_portal_target: Option<String>,
242    /// Same-domain world name used by End portals from non-End worlds.
243    #[serde(default)]
244    pub end_portal_target: Option<String>,
245    /// Generator-specific config. The selected generator validates this strictly.
246    #[serde(default)]
247    pub config: Option<toml::Value>,
248}
249
250/// Registry-backed storage selection from config.
251#[derive(Debug, Clone, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct StorageSelection {
254    /// Storage backend identifier.
255    #[serde(rename = "type")]
256    pub kind: Identifier,
257    /// Storage-specific config. The selected storage backend validates this strictly.
258    #[serde(default)]
259    pub config: Option<toml::Value>,
260}
261
262impl StorageSelection {
263    /// Default disk world storage.
264    #[must_use]
265    pub fn default_world_disk() -> Self {
266        Self {
267            kind: Identifier::from_steel("disk"),
268            config: None,
269        }
270    }
271
272    /// Default file-backed player storage.
273    #[must_use]
274    pub fn default_player_file() -> Self {
275        Self {
276            kind: Identifier::from_steel("file"),
277            config: None,
278        }
279    }
280
281    /// Returns the config table or an empty table if omitted.
282    #[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/// Fully validated and cascaded worlds config.
291#[derive(Debug, Clone)]
292pub struct ResolvedWorldsConfig {
293    /// Root directory for save data.
294    pub save_path: PathBuf,
295    /// Default domain name.
296    pub default_domain: String,
297    /// Resolved domain configs.
298    pub domains: Vec<ResolvedDomainConfig>,
299    /// Resolved world configs in startup creation order.
300    pub worlds: Vec<ResolvedWorldConfig>,
301    /// Player data storage selection.
302    pub player_storage: StorageSelection,
303}
304
305/// Validated domain config.
306#[derive(Debug, Clone)]
307pub struct ResolvedDomainConfig {
308    /// Domain name.
309    pub name: String,
310    /// Default world identifier in this domain.
311    pub default_world: Identifier,
312    /// World identifiers in this domain.
313    pub worlds: Vec<Identifier>,
314}
315
316/// Validated world startup config.
317#[derive(Debug, Clone)]
318pub struct ResolvedWorldConfig {
319    /// Loaded world identifier (`domain:world`).
320    pub key: Identifier,
321    /// Domain name.
322    pub domain: String,
323    /// World path/name inside the domain.
324    pub name: String,
325    /// Generator factory identifier.
326    pub generator: Identifier,
327    /// Strictly validated generator config.
328    pub generator_config: ValidatedWorldGeneratorConfig,
329    /// Resolved world seed.
330    pub seed: i64,
331    /// Default game mode for first-visit player data in this world.
332    pub default_gamemode: GameType,
333    /// Difficulty for new level data in this world.
334    pub difficulty: Difficulty,
335    /// Resolved world storage selection.
336    pub storage: StorageSelection,
337    /// Explicit same-domain Nether portal target, if configured.
338    pub nether_portal_target: Option<Identifier>,
339    /// Explicit same-domain End portal entry target, if configured.
340    pub end_portal_target: Option<Identifier>,
341}
342
343impl WorldsConfig {
344    /// Validates `worlds.toml` and resolves cascaded defaults.
345    ///
346    /// # Errors
347    /// Returns a human-readable startup error if any invariant is violated.
348    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
708/// Validates a config path as a relative path under a Steel-owned root.
709pub 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}