Skip to main content

steel_core/chunk_saver/
registry.rs

1//! Runtime registry for world storage backends.
2
3use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashMap;
6use serde::Deserialize;
7use steel_utils::Identifier;
8
9use crate::config::{
10    ResolvedWorldsConfig, StorageSelection, WorldStorageConfig, validate_relative_path,
11};
12
13/// Storage paths and backend for a loaded world.
14pub struct WorldStorageOutput {
15    /// Chunk storage backend config.
16    pub storage: WorldStorageConfig,
17    /// Directory containing level data, if persistent.
18    pub level_data_path: Option<PathBuf>,
19}
20
21struct WorldStorageFactory {
22    validate: fn(&toml::Value) -> Result<(), String>,
23    create: fn(&toml::Value, &Path, &Path) -> Result<WorldStorageOutput, String>,
24}
25
26/// Registry of server-side world storage factories.
27pub struct WorldStorageRegistry {
28    factories: FxHashMap<Identifier, WorldStorageFactory>,
29}
30
31impl WorldStorageRegistry {
32    /// Creates a registry containing Steel's built-in world storage backends.
33    pub fn new_with_builtins() -> Result<Self, String> {
34        let mut registry = Self {
35            factories: FxHashMap::default(),
36        };
37        registry.register(
38            Identifier::from_steel("disk"),
39            WorldStorageFactory {
40                validate: validate_disk_config,
41                create: create_disk_storage,
42            },
43        )?;
44        registry.register(
45            Identifier::from_steel("ram"),
46            WorldStorageFactory {
47                validate: validate_empty_config,
48                create: create_ram_storage,
49            },
50        )?;
51        Ok(registry)
52    }
53
54    fn register(&mut self, key: Identifier, factory: WorldStorageFactory) -> Result<(), String> {
55        if self.factories.insert(key.clone(), factory).is_some() {
56            return Err(format!("duplicate world storage registration {key}"));
57        }
58        Ok(())
59    }
60
61    /// Validates a storage selection.
62    pub fn validate_selection(&self, selection: &StorageSelection) -> Result<(), String> {
63        let factory = self
64            .factories
65            .get(&selection.kind)
66            .ok_or_else(|| format!("unknown world storage {}", selection.kind))?;
67        (factory.validate)(&selection.config_value())
68    }
69
70    /// Resolves storage and checks domain clock durability before any world is loaded.
71    pub(crate) fn resolve_worlds(
72        &self,
73        config: &ResolvedWorldsConfig,
74    ) -> Result<FxHashMap<Identifier, WorldStorageOutput>, String> {
75        let mut outputs = FxHashMap::default();
76        for world in &config.worlds {
77            let path = config
78                .save_path
79                .join(&world.domain)
80                .join("worlds")
81                .join(&world.name);
82            let output = self
83                .create(&world.storage, &config.save_path, &path)
84                .map_err(|error| format!("failed to create storage for {}: {error}", world.key))?;
85            outputs.insert(world.key.clone(), output);
86        }
87        for domain in &config.domains {
88            let primary = outputs
89                .get(&domain.default_world)
90                .ok_or_else(|| format!("domain {} has no primary storage", domain.name))?;
91            if primary.level_data_path.is_some() {
92                continue;
93            }
94            for key in &domain.worlds {
95                let output = outputs
96                    .get(key)
97                    .ok_or_else(|| format!("world {key} has no resolved storage"))?;
98                if output.level_data_path.is_some()
99                    || matches!(output.storage, WorldStorageConfig::Disk { .. })
100                {
101                    return Err(format!(
102                        "domain {} default world {} must persist level data because world {key} uses persistent storage",
103                        domain.name, domain.default_world
104                    ));
105                }
106            }
107        }
108        Ok(outputs)
109    }
110
111    /// Creates a resolved world storage config.
112    pub fn create(
113        &self,
114        selection: &StorageSelection,
115        save_root: &Path,
116        default_world_path: &Path,
117    ) -> Result<WorldStorageOutput, String> {
118        let factory = self
119            .factories
120            .get(&selection.kind)
121            .ok_or_else(|| format!("unknown world storage {}", selection.kind))?;
122        (factory.create)(&selection.config_value(), save_root, default_world_path)
123    }
124}
125
126#[derive(Deserialize)]
127#[serde(deny_unknown_fields)]
128struct DiskStorageConfig {
129    path: Option<String>,
130}
131
132fn validate_disk_config(config: &toml::Value) -> Result<(), String> {
133    let parsed: DiskStorageConfig = config
134        .clone()
135        .try_into()
136        .map_err(|e| format!("invalid steel:disk config: {e}"))?;
137    if let Some(path) = parsed.path {
138        validate_relative_path(&path, "storage.config.path")?;
139    }
140    Ok(())
141}
142
143fn validate_empty_config(config: &toml::Value) -> Result<(), String> {
144    let Some(table) = config.as_table() else {
145        return Err("storage config must be a table".to_owned());
146    };
147    if !table.is_empty() {
148        return Err("this storage backend does not accept config".to_owned());
149    }
150    Ok(())
151}
152
153fn create_disk_storage(
154    config: &toml::Value,
155    save_root: &Path,
156    default_world_path: &Path,
157) -> Result<WorldStorageOutput, String> {
158    let parsed: DiskStorageConfig = config
159        .clone()
160        .try_into()
161        .map_err(|e| format!("invalid steel:disk config: {e}"))?;
162    let path = parsed.path.map_or_else(
163        || default_world_path.to_path_buf(),
164        |path| save_root.join(path),
165    );
166    Ok(WorldStorageOutput {
167        storage: WorldStorageConfig::Disk {
168            path: path_to_string(path.join("region")),
169        },
170        level_data_path: Some(path),
171    })
172}
173
174fn create_ram_storage(
175    config: &toml::Value,
176    _save_root: &Path,
177    _default_world_path: &Path,
178) -> Result<WorldStorageOutput, String> {
179    validate_empty_config(config)?;
180    Ok(WorldStorageOutput {
181        storage: WorldStorageConfig::RamOnly,
182        level_data_path: None,
183    })
184}
185
186fn path_to_string(path: PathBuf) -> String {
187    path.to_string_lossy().into_owned()
188}
189
190#[cfg(test)]
191mod tests {
192    use steel_registry::init_vanilla_registry;
193
194    use crate::config::{DomainConfig, StorageSelection, WorldEntryConfig, WorldsConfig};
195    use crate::worldgen::generator::registry::WorldGeneratorRegistry;
196    use steel_utils::Identifier;
197
198    use super::WorldStorageRegistry;
199
200    #[test]
201    fn domain_storage_accepts_durable_primary_or_entirely_ephemeral_worlds() {
202        init_vanilla_registry();
203        let storage = WorldStorageRegistry::new_with_builtins().expect("storage registry");
204        let generators = WorldGeneratorRegistry::new_with_builtins().expect("generators");
205        for (primary, derived) in [("disk", "disk"), ("disk", "ram"), ("ram", "ram")] {
206            let worlds = [("primary", primary, true), ("derived", derived, false)]
207                .into_iter()
208                .map(|(name, backend, default)| WorldEntryConfig {
209                    name: name.to_owned(),
210                    generator: Identifier::new_static("minecraft", "flat"),
211                    default,
212                    seed: None,
213                    default_gamemode: None,
214                    difficulty: None,
215                    storage: Some(StorageSelection {
216                        kind: Identifier::new_static("steel", backend),
217                        config: None,
218                    }),
219                    nether_portal_target: None,
220                    end_portal_target: None,
221                    config: None,
222                })
223                .collect();
224            let config = WorldsConfig {
225                save_path: "saves".to_owned(),
226                seed: None,
227                default_gamemode: None,
228                difficulty: None,
229                storage: None,
230                player_storage: None,
231                domains: [(
232                    "example".to_owned(),
233                    DomainConfig {
234                        default: true,
235                        seed: None,
236                        default_gamemode: None,
237                        difficulty: None,
238                        storage: None,
239                        worlds,
240                    },
241                )]
242                .into(),
243            };
244
245            let resolved = config
246                .validate_and_resolve(&generators, &storage)
247                .expect("resolve");
248            storage
249                .resolve_worlds(&resolved)
250                .expect("valid storage combination");
251        }
252    }
253}