Skip to main content

dotzuki_engine_script/
command.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4pub enum ScriptCommand {
5    ShowText {
6        text: String,
7    },
8    ShowChoice {
9        options: Vec<String>,
10    },
11    GiveItem {
12        item_id: String,
13        quantity: u8,
14    },
15    GiveMonster {
16        species: String,
17        level: u8,
18    },
19    TakeItem {
20        item_id: String,
21        quantity: u8,
22    },
23    SetFlag {
24        flag: String,
25    },
26    ResetFlag {
27        flag: String,
28    },
29    CheckFlag {
30        flag: String,
31    },
32    ShowObject {
33        object_index: u8,
34    },
35    HideObject {
36        object_index: u8,
37    },
38    ShowObjectByName {
39        toggle_id: String,
40    },
41    HideObjectByName {
42        toggle_id: String,
43    },
44    MoveNpc {
45        npc_id: String,
46        path: Vec<(u8, u8)>,
47    },
48    StartNpcMove {
49        npc_id: String,
50        path: Vec<(u8, u8)>,
51    },
52    AwaitNpcMove {
53        npc_id: String,
54    },
55    MovePlayer {
56        path: Vec<(u8, u8)>,
57    },
58    /// Relative player steps: each entry is a (dx, dy) delta applied
59    /// cumulatively from the player's current position. Direction
60    /// strings ("up"/"down"/"left"/"right") are converted to unit
61    /// deltas at parse time.
62    MovePlayerRelative {
63        steps: Vec<(i16, i16)>,
64    },
65    MoveNpcTo {
66        npc_id: String,
67        x: u8,
68        y: u8,
69    },
70    StartNpcMoveTo {
71        npc_id: String,
72        x: u8,
73        y: u8,
74    },
75    MovePlayerTo {
76        x: u8,
77        y: u8,
78    },
79    FaceNpc {
80        npc_id: String,
81        direction: String,
82    },
83    FacePlayer {
84        direction: String,
85    },
86    PlayMusic {
87        music_id: String,
88    },
89    PlaySound {
90        sound_id: String,
91    },
92    StopMusic,
93    FadeOutMusic,
94    StartBattle {
95        trainer_id: String,
96    },
97    /// Start a wild/static battle against a single generated opponent of the
98    /// given species and level (catchable, like a random encounter). Resolves
99    /// to the battle outcome string ("win" | "lose" | "caught" | "fled" | ...).
100    StartWildBattle {
101        species: String,
102        level: u8,
103    },
104    /// Arm the battle-local weather for the NEXT battle (`Some(id)` names a
105    /// `kind: Weather` rules.ron record; `None` clears a previously armed
106    /// one). Runner-local: registered by the jrpg runner's scene engine next
107    /// to `startBattle`, consumed before the battle starts, cleared when the
108    /// battle ends. Never saved.
109    SetWeather {
110        weather: Option<String>,
111    },
112    Delay {
113        frames: u16,
114    },
115    WarpTo {
116        map: String,
117        x: u8,
118        y: u8,
119    },
120    Heal,
121    FadeScreen {
122        fade_type: String,
123    },
124    SetJoyIgnore {
125        mask: u8,
126    },
127    ClearJoyIgnore,
128    FollowNpc {
129        npc_id: String,
130        target_x: u8,
131        target_y: u8,
132    },
133    OpenShop {
134        items: Vec<String>,
135    },
136    ShowEmotionBubble {
137        npc_id: String,
138        emotion: String,
139    },
140    SetNpcPosition {
141        npc_id: String,
142        x: u8,
143        y: u8,
144    },
145    SetNpcFrame {
146        npc_id: String,
147        frame: u8,
148    },
149    ShowScene {
150        scene_name: String,
151        layout_json: Option<String>,
152    },
153    HideScene {
154        scene_name: String,
155    },
156    UpdateUI {
157        scene_name: String,
158        data_json: String,
159    },
160    GiveMoney {
161        amount: u32,
162    },
163    TakeMoney {
164        amount: u32,
165    },
166    PlayCry {
167        species: String,
168    },
169    GiveBadge {
170        badge: u8,
171    },
172    /// A game-defined command outside the generic JRPG protocol.
173    ///
174    /// The game registers the JS verb through its `ScriptApiRegistrar`
175    /// (returning this variant with the verb's name and arguments) and
176    /// dispatches on `name`/`args` in its own app layer.
177    Custom {
178        name: String,
179        args: Vec<serde_json::Value>,
180    },
181}
182
183#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
184pub enum CommandResult {
185    Void,
186    Bool(bool),
187    Number(f64),
188    Text(String),
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_show_text_command() {
197        let cmd = ScriptCommand::ShowText {
198            text: "Hello".to_string(),
199        };
200        assert_eq!(
201            cmd,
202            ScriptCommand::ShowText {
203                text: "Hello".to_string()
204            }
205        );
206    }
207
208    #[test]
209    fn test_show_choice_command() {
210        let cmd = ScriptCommand::ShowChoice {
211            options: vec!["Yes".to_string(), "No".to_string()],
212        };
213        assert_eq!(
214            cmd,
215            ScriptCommand::ShowChoice {
216                options: vec!["Yes".to_string(), "No".to_string()]
217            }
218        );
219    }
220
221    #[test]
222    fn test_serialize_deserialize_roundtrip() {
223        let cmds: Vec<ScriptCommand> = vec![
224            ScriptCommand::ShowText {
225                text: "Hello".into(),
226            },
227            ScriptCommand::ShowChoice {
228                options: vec!["A".into(), "B".into()],
229            },
230            ScriptCommand::GiveItem {
231                item_id: "POTION".into(),
232                quantity: 5,
233            },
234            ScriptCommand::GiveMonster {
235                species: "SPARKIT".into(),
236                level: 5,
237            },
238            ScriptCommand::Heal,
239            ScriptCommand::StopMusic,
240            ScriptCommand::ClearJoyIgnore,
241            ScriptCommand::WarpTo {
242                map: "START_TOWN".into(),
243                x: 5,
244                y: 3,
245            },
246            ScriptCommand::Delay { frames: 60 },
247            ScriptCommand::PlayMusic {
248                music_id: "START_TOWN".into(),
249            },
250            ScriptCommand::PlaySound {
251                sound_id: "SFX_BALL".into(),
252            },
253            ScriptCommand::FadeOutMusic,
254            ScriptCommand::FadeScreen {
255                fade_type: "out".into(),
256            },
257            ScriptCommand::SetJoyIgnore { mask: 0xFF },
258            ScriptCommand::MoveNpc {
259                npc_id: "prof".into(),
260                path: vec![(2, 3), (4, 5)],
261            },
262            ScriptCommand::FollowNpc {
263                npc_id: "rival".into(),
264                target_x: 10,
265                target_y: 8,
266            },
267            ScriptCommand::OpenShop {
268                items: vec!["POTION".into()],
269            },
270            ScriptCommand::GiveMoney { amount: 500 },
271            ScriptCommand::TakeMoney { amount: 100 },
272            ScriptCommand::GiveBadge { badge: 0 },
273            ScriptCommand::Custom {
274                name: "tradeMonster".into(),
275                args: vec![serde_json::json!("SPARKIT")],
276            },
277            ScriptCommand::ShowObject { object_index: 1 },
278            ScriptCommand::HideObjectByName {
279                toggle_id: "HIDDEN_ITEM".into(),
280            },
281            ScriptCommand::SetWeather {
282                weather: Some("sandstorm".into()),
283            },
284            ScriptCommand::SetWeather { weather: None },
285        ];
286
287        for cmd in &cmds {
288            let json = serde_json::to_string(cmd).unwrap();
289            let deserialized: ScriptCommand = serde_json::from_str(&json).unwrap();
290            assert_eq!(*cmd, deserialized, "round-trip failed for {cmd:?}");
291        }
292    }
293
294    #[test]
295    fn test_command_result_equality() {
296        assert_eq!(CommandResult::Void, CommandResult::Void);
297        assert_eq!(CommandResult::Bool(true), CommandResult::Bool(true));
298        assert_eq!(CommandResult::Bool(false), CommandResult::Bool(false));
299        assert_ne!(CommandResult::Bool(true), CommandResult::Bool(false));
300        assert_eq!(CommandResult::Number(42.0), CommandResult::Number(42.0));
301        assert_ne!(CommandResult::Number(1.0), CommandResult::Number(2.0));
302        assert_eq!(
303            CommandResult::Text("hello".into()),
304            CommandResult::Text("hello".into())
305        );
306        assert_ne!(
307            CommandResult::Text("hello".into()),
308            CommandResult::Text("world".into())
309        );
310    }
311
312    #[test]
313    fn test_command_result_debug() {
314        let void = CommandResult::Void;
315        assert!(!format!("{void:?}").is_empty());
316
317        let b = CommandResult::Bool(true);
318        assert_eq!(format!("{b:?}"), "Bool(true)");
319
320        let n = CommandResult::Number(2.5);
321        assert!(format!("{n:?}").contains("2.5"));
322
323        let t = CommandResult::Text("result".into());
324        assert_eq!(format!("{t:?}"), "Text(\"result\")");
325    }
326
327    #[test]
328    fn test_give_take_money() {
329        let give = ScriptCommand::GiveMoney { amount: 999 };
330        let take = ScriptCommand::TakeMoney { amount: 50 };
331        assert_ne!(give, take);
332        if let ScriptCommand::GiveMoney { amount } = &give {
333            assert_eq!(*amount, 999);
334        } else {
335            panic!("expected GiveMoney");
336        }
337    }
338
339    #[test]
340    fn test_show_hide_scene() {
341        let show = ScriptCommand::ShowScene {
342            scene_name: "shop".into(),
343            layout_json: None,
344        };
345        let hide = ScriptCommand::HideScene {
346            scene_name: "shop".into(),
347        };
348        assert_ne!(show, hide);
349        assert_eq!(
350            show,
351            ScriptCommand::ShowScene {
352                scene_name: "shop".into(),
353                layout_json: None
354            }
355        );
356    }
357
358    #[test]
359    fn test_update_ui_command() {
360        let cmd = ScriptCommand::UpdateUI {
361            scene_name: "bag".into(),
362            data_json: r#"{"gold":500}"#.into(),
363        };
364        assert!(format!("{cmd:?}").contains("bag"));
365        assert!(format!("{cmd:?}").contains("gold"));
366    }
367}