Skip to main content

dotzuki_engine_script/
loader.rs

1use std::collections::HashMap;
2
3use crate::MapScriptConfig;
4
5#[derive(Debug, Clone)]
6pub struct ScriptSource {
7    pub map_id: String,
8    pub source: String,
9}
10
11#[cfg(not(target_arch = "wasm32"))]
12#[derive(Debug, Clone)]
13struct ScriptFileMeta {
14    path: std::path::PathBuf,
15    modified: std::time::SystemTime,
16}
17
18pub struct ScriptLoader {
19    scripts: HashMap<String, String>,
20    configs: HashMap<String, MapScriptConfig>,
21    #[cfg(not(target_arch = "wasm32"))]
22    file_meta: HashMap<String, ScriptFileMeta>,
23}
24
25impl ScriptLoader {
26    pub fn new() -> Self {
27        Self {
28            scripts: HashMap::new(),
29            configs: HashMap::new(),
30            #[cfg(not(target_arch = "wasm32"))]
31            file_meta: HashMap::new(),
32        }
33    }
34
35    pub fn register_script(&mut self, map_id: &str, source: &str) {
36        self.scripts.insert(map_id.to_string(), source.to_string());
37    }
38
39    pub fn register_config(&mut self, map_id: &str, config: MapScriptConfig) {
40        self.configs.insert(map_id.to_string(), config);
41    }
42
43    pub fn register_config_json(&mut self, map_id: &str, json: &str) -> Result<(), String> {
44        let config: MapScriptConfig = serde_json::from_str(json)
45            .map_err(|e| format!("JSON parse error for {}: {}", map_id, e))?;
46        self.configs.insert(map_id.to_string(), config);
47        Ok(())
48    }
49
50    pub fn get_script(&self, map_id: &str) -> Option<&str> {
51        self.scripts.get(map_id).map(|s| s.as_str())
52    }
53
54    pub fn get_config(&self, map_id: &str) -> Option<&MapScriptConfig> {
55        self.configs.get(map_id)
56    }
57
58    pub fn has_script(&self, map_id: &str) -> bool {
59        self.scripts.contains_key(map_id)
60    }
61
62    pub fn has_config(&self, map_id: &str) -> bool {
63        self.configs.contains_key(map_id)
64    }
65
66    pub fn loaded_maps(&self) -> Vec<&str> {
67        self.scripts.keys().map(|s| s.as_str()).collect()
68    }
69
70    #[cfg(not(target_arch = "wasm32"))]
71    pub fn load_from_directory(
72        &mut self,
73        dir: &std::path::Path,
74    ) -> Result<usize, ScriptLoaderError> {
75        use std::fs;
76
77        if !dir.is_dir() {
78            return Err(ScriptLoaderError::NotADirectory(
79                dir.to_string_lossy().to_string(),
80            ));
81        }
82
83        let shared_dir = dir.join("shared");
84        if shared_dir.is_dir() {
85            log::info!(target: "dotzuki::overworld", "[ScriptLoader] Loading shared modules from {:?}", shared_dir);
86            for entry in fs::read_dir(&shared_dir)
87                .map_err(|e| ScriptLoaderError::IoError(shared_dir.to_string_lossy().to_string(), e))?
88            {
89                let entry = entry
90                    .map_err(|e| ScriptLoaderError::IoError(shared_dir.to_string_lossy().to_string(), e))?;
91                let path = entry.path();
92                if path.is_file() {
93                    if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
94                        if let Ok(content) = fs::read_to_string(&path) {
95                            let key = format!("shared/{}", name);
96                            log::info!(target: "dotzuki::overworld", "[ScriptLoader] Registered shared module: {} ({} bytes)", key, content.len());
97                            self.scripts.insert(key, content);
98                        }
99                    }
100                }
101            }
102        }
103
104        let mut count = 0;
105        for entry in fs::read_dir(dir)
106            .map_err(|e| ScriptLoaderError::IoError(dir.to_string_lossy().to_string(), e))?
107        {
108            let entry = entry
109                .map_err(|e| ScriptLoaderError::IoError(dir.to_string_lossy().to_string(), e))?;
110            let path = entry.path();
111
112            if !path.is_dir() {
113                continue;
114            }
115
116            let map_id = path
117                .file_name()
118                .and_then(|s| s.to_str())
119                .ok_or_else(|| {
120                    ScriptLoaderError::InvalidFileName(path.to_string_lossy().to_string())
121                })?
122                .to_string();
123
124            let js_path = path.join("script.js");
125            if js_path.is_file() {
126                let content = fs::read_to_string(&js_path).map_err(|e| {
127                    ScriptLoaderError::IoError(js_path.to_string_lossy().to_string(), e)
128                })?;
129
130                let modified = fs::metadata(&js_path)
131                    .and_then(|m| m.modified())
132                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
133
134                self.scripts.insert(map_id.clone(), content);
135                self.file_meta.insert(
136                    format!("{}:js", map_id),
137                    ScriptFileMeta {
138                        path: js_path,
139                        modified,
140                    },
141                );
142                count += 1;
143            }
144
145            let config_path = path.join("script_config.json");
146            if config_path.is_file() {
147                let content = fs::read_to_string(&config_path).map_err(|e| {
148                    ScriptLoaderError::IoError(config_path.to_string_lossy().to_string(), e)
149                })?;
150
151                let modified = fs::metadata(&config_path)
152                    .and_then(|m| m.modified())
153                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
154
155                let config: MapScriptConfig = serde_json::from_str(&content).map_err(|e| {
156                    ScriptLoaderError::IoError(
157                        config_path.to_string_lossy().to_string(),
158                        std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
159                    )
160                })?;
161                self.configs.insert(map_id.clone(), config);
162                self.file_meta.insert(
163                    format!("{}:json", map_id),
164                    ScriptFileMeta {
165                        path: config_path,
166                        modified,
167                    },
168                );
169                count += 1;
170            }
171        }
172
173        log::info!("ScriptLoader: loaded {} files from {:?}", count, dir);
174        Ok(count)
175    }
176
177    #[cfg(not(target_arch = "wasm32"))]
178    pub fn check_reload(&mut self) -> Vec<String> {
179        use std::fs;
180
181        let mut reloaded = Vec::new();
182
183        let entries: Vec<(String, std::path::PathBuf, std::time::SystemTime)> = self
184            .file_meta
185            .iter()
186            .map(|(id, meta)| (id.clone(), meta.path.clone(), meta.modified))
187            .collect();
188
189        for (meta_key, path, old_modified) in entries {
190            let current_modified = match fs::metadata(&path).and_then(|m| m.modified()) {
191                Ok(t) => t,
192                Err(_) => continue,
193            };
194
195            if current_modified > old_modified {
196                match fs::read_to_string(&path) {
197                    Ok(content) => {
198                        let ext = path.extension().and_then(|e| e.to_str());
199                        let map_id = path
200                            .parent()
201                            .and_then(|p| p.file_name())
202                            .and_then(|s| s.to_str())
203                            .unwrap_or("")
204                            .to_string();
205
206                        match ext {
207                            Some("js") => {
208                                self.scripts.insert(map_id.clone(), content);
209                            }
210                            Some("json") => {
211                                if let Ok(config) =
212                                    serde_json::from_str::<MapScriptConfig>(&content)
213                                {
214                                    self.configs.insert(map_id.clone(), config);
215                                }
216                            }
217                            _ => {}
218                        }
219
220                        if let Some(meta) = self.file_meta.get_mut(&meta_key) {
221                            meta.modified = current_modified;
222                        }
223                        log::info!("ScriptLoader: hot-reloaded {:?}", path);
224                        reloaded.push(map_id);
225                    }
226                    Err(e) => {
227                        log::warn!("ScriptLoader: failed to reload {:?}: {}", path, e);
228                    }
229                }
230            }
231        }
232
233        reloaded
234    }
235
236    #[cfg(feature = "embedded-scripts")]
237    pub fn load_embedded(&mut self) -> usize {
238        crate::embedded_scripts::load_embedded_scripts(self);
239        self.scripts.len()
240    }
241
242    #[cfg(feature = "embedded-scripts")]
243    pub fn load_auto(
244        &mut self,
245        _scripts_dir: Option<&std::path::Path>,
246    ) -> Result<usize, ScriptLoaderError> {
247        let count = self.load_embedded();
248        Ok(count)
249    }
250
251    #[cfg(all(not(feature = "embedded-scripts"), not(target_arch = "wasm32")))]
252    pub fn load_auto(
253        &mut self,
254        scripts_dir: Option<&std::path::Path>,
255    ) -> Result<usize, ScriptLoaderError> {
256        if let Some(dir) = scripts_dir {
257            return self.load_from_directory(dir);
258        }
259
260        Err(ScriptLoaderError::NotADirectory(
261            "no scripts directory provided (auto-detection is no longer \
262             baked into the engine; games pass their own --scripts-dir or use \
263             their own embedded scene provider)"
264                .to_string(),
265        ))
266    }
267
268    #[cfg(all(not(feature = "embedded-scripts"), target_arch = "wasm32"))]
269    pub fn load_auto(
270        &mut self,
271        _scripts_dir: Option<&std::path::Path>,
272    ) -> Result<usize, ScriptLoaderError> {
273        // wasm32 cannot load scripts from disk; embedded-scripts feature required for runtime use.
274        // Stub returns Ok(0) so wasm builds compile; real wasm consumers (preview crate) must use embedded-scripts.
275        Ok(0)
276    }
277}
278
279impl Default for ScriptLoader {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285#[derive(Debug)]
286pub enum ScriptLoaderError {
287    NotADirectory(String),
288    IoError(String, std::io::Error),
289    InvalidFileName(String),
290}
291
292impl std::fmt::Display for ScriptLoaderError {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        match self {
295            Self::NotADirectory(path) => write!(f, "Not a directory: {}", path),
296            Self::IoError(path, err) => write!(f, "IO error at {}: {}", path, err),
297            Self::InvalidFileName(path) => write!(f, "Invalid file name: {}", path),
298        }
299    }
300}
301
302impl std::error::Error for ScriptLoaderError {}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::config::NpcBinding;
308
309    #[test]
310    fn test_register_and_get() {
311        let mut loader = ScriptLoader::new();
312        loader.register_script("TestMap", "function onEnter() {}");
313        assert!(loader.has_script("TestMap"));
314        assert_eq!(loader.get_script("TestMap"), Some("function onEnter() {}"));
315    }
316
317    #[test]
318    fn test_register_config_json() {
319        let mut loader = ScriptLoader::new();
320        let json = r#"{
321            "npcs": [{"id": 1, "talk": "talkProf"}],
322            "signs": [{"id": 1, "talk": "signLab"}],
323            "coordEvents": [{"name": "enterRoute1", "position": [4, 1], "trigger": "enterRoute1"}]
324        }"#;
325        loader.register_config_json("TestMap", json).unwrap();
326        assert!(loader.has_config("TestMap"));
327        let config = loader.get_config("TestMap").unwrap();
328        assert_eq!(config.npcs.len(), 1);
329        assert_eq!(config.npc_talk_fn(1), Some("talkProf"));
330        assert_eq!(config.sign_talk_fn(1), Some("signLab"));
331        assert_eq!(config.coord_event_fn(4, 1), Some("enterRoute1"));
332    }
333
334    #[test]
335    fn test_get_script_missing() {
336        let loader = ScriptLoader::new();
337        assert_eq!(loader.get_script("NonExistentMap"), None);
338    }
339
340    #[test]
341    fn test_get_config_missing() {
342        let loader = ScriptLoader::new();
343        assert!(loader.get_config("NonExistentMap").is_none());
344    }
345
346    #[test]
347    fn test_has_script_false() {
348        let loader = ScriptLoader::new();
349        assert!(!loader.has_script("anything"));
350    }
351
352    #[test]
353    fn test_has_config_false() {
354        let loader = ScriptLoader::new();
355        assert!(!loader.has_config("anything"));
356    }
357
358    #[test]
359    fn test_loaded_maps_empty() {
360        let loader = ScriptLoader::new();
361        assert!(loader.loaded_maps().is_empty());
362    }
363
364    #[test]
365    fn test_loaded_maps_multiple() {
366        let mut loader = ScriptLoader::new();
367        loader.register_script("MapA", "script A");
368        loader.register_script("MapB", "script B");
369        loader.register_script("MapC", "script C");
370
371        let mut maps: Vec<&str> = loader.loaded_maps();
372        maps.sort();
373        assert_eq!(maps, vec!["MapA", "MapB", "MapC"]);
374    }
375
376    #[test]
377    fn test_register_config_direct() {
378        let mut loader = ScriptLoader::new();
379        let config = MapScriptConfig {
380            on_load: Some("onEnter".into()),
381            npcs: vec![NpcBinding {
382                id: 1,
383                talk: Some("talkProf".into()),
384                toggle_id: None,
385                script_id: None,
386                default_hidden: false,
387            }],
388            signs: vec![],
389            coord_events: vec![],
390        };
391        loader.register_config("TestMap", config);
392        assert!(loader.has_config("TestMap"));
393        let loaded = loader.get_config("TestMap").unwrap();
394        assert_eq!(loaded.on_load(), Some("onEnter"));
395        assert_eq!(loaded.npc_talk_fn(1), Some("talkProf"));
396    }
397
398    #[test]
399    fn test_register_config_json_invalid() {
400        let mut loader = ScriptLoader::new();
401        let result = loader.register_config_json("BadMap", "not valid json");
402        assert!(result.is_err());
403        assert!(!loader.has_config("BadMap"));
404    }
405
406    #[test]
407    fn test_register_and_overwrite_script() {
408        let mut loader = ScriptLoader::new();
409        loader.register_script("Map", "version1");
410        assert_eq!(loader.get_script("Map"), Some("version1"));
411
412        loader.register_script("Map", "version2");
413        assert_eq!(loader.get_script("Map"), Some("version2"));
414    }
415
416    #[test]
417    fn test_loader_default() {
418        let loader: ScriptLoader = Default::default();
419        assert!(loader.loaded_maps().is_empty());
420    }
421}