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