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