1use 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
13pub struct WorldStorageOutput {
15 pub storage: WorldStorageConfig,
17 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
26pub struct WorldStorageRegistry {
28 factories: FxHashMap<Identifier, WorldStorageFactory>,
29}
30
31impl WorldStorageRegistry {
32 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 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 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 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}