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 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}