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(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!(cmd, ScriptCommand::ShowText { text: "Hello".to_string() });
201    }
202
203    #[test]
204    fn test_show_choice_command() {
205        let cmd = ScriptCommand::ShowChoice {
206            options: vec!["Yes".to_string(), "No".to_string()],
207        };
208        assert_eq!(
209            cmd,
210            ScriptCommand::ShowChoice {
211                options: vec!["Yes".to_string(), "No".to_string()]
212            }
213        );
214    }
215
216    #[test]
217    fn test_serialize_deserialize_roundtrip() {
218        let cmds: Vec<ScriptCommand> = vec![
219            ScriptCommand::ShowText { text: "Hello".into() },
220            ScriptCommand::ShowChoice { options: vec!["A".into(), "B".into()] },
221            ScriptCommand::GiveItem { item_id: "POTION".into(), quantity: 5 },
222            ScriptCommand::GiveMonster { species: "SPARKIT".into(), level: 5 },
223            ScriptCommand::Heal,
224            ScriptCommand::StopMusic,
225            ScriptCommand::ClearJoyIgnore,
226            ScriptCommand::WarpTo { map: "START_TOWN".into(), x: 5, y: 3 },
227            ScriptCommand::Delay { frames: 60 },
228            ScriptCommand::PlayMusic { music_id: "START_TOWN".into() },
229            ScriptCommand::PlaySound { sound_id: "SFX_BALL".into() },
230            ScriptCommand::FadeOutMusic,
231            ScriptCommand::FadeScreen { fade_type: "out".into() },
232            ScriptCommand::SetJoyIgnore { mask: 0xFF },
233            ScriptCommand::MoveNpc { npc_id: "prof".into(), path: vec![(2, 3), (4, 5)] },
234            ScriptCommand::FollowNpc { npc_id: "rival".into(), target_x: 10, target_y: 8 },
235            ScriptCommand::OpenShop { items: vec!["POTION".into()] },
236            ScriptCommand::GiveMoney { amount: 500 },
237            ScriptCommand::TakeMoney { amount: 100 },
238            ScriptCommand::GiveBadge { badge: 0 },
239            ScriptCommand::Custom {
240                name: "tradeMonster".into(),
241                args: vec![serde_json::json!("SPARKIT")],
242            },
243            ScriptCommand::ShowObject { object_index: 1 },
244            ScriptCommand::HideObjectByName { toggle_id: "HIDDEN_ITEM".into() },
245            ScriptCommand::SetWeather { weather: Some("sandstorm".into()) },
246            ScriptCommand::SetWeather { weather: None },
247        ];
248
249        for cmd in &cmds {
250            let json = serde_json::to_string(cmd).unwrap();
251            let deserialized: ScriptCommand = serde_json::from_str(&json).unwrap();
252            assert_eq!(*cmd, deserialized, "round-trip failed for {cmd:?}");
253        }
254    }
255
256    #[test]
257    fn test_command_result_equality() {
258        assert_eq!(CommandResult::Void, CommandResult::Void);
259        assert_eq!(CommandResult::Bool(true), CommandResult::Bool(true));
260        assert_eq!(CommandResult::Bool(false), CommandResult::Bool(false));
261        assert_ne!(CommandResult::Bool(true), CommandResult::Bool(false));
262        assert_eq!(CommandResult::Number(42.0), CommandResult::Number(42.0));
263        assert_ne!(CommandResult::Number(1.0), CommandResult::Number(2.0));
264        assert_eq!(
265            CommandResult::Text("hello".into()),
266            CommandResult::Text("hello".into())
267        );
268        assert_ne!(
269            CommandResult::Text("hello".into()),
270            CommandResult::Text("world".into())
271        );
272    }
273
274    #[test]
275    fn test_command_result_debug() {
276        let void = CommandResult::Void;
277        assert!(!format!("{void:?}").is_empty());
278
279        let b = CommandResult::Bool(true);
280        assert_eq!(format!("{b:?}"), "Bool(true)");
281
282        let n = CommandResult::Number(2.5);
283        assert!(format!("{n:?}").contains("2.5"));
284
285        let t = CommandResult::Text("result".into());
286        assert_eq!(format!("{t:?}"), "Text(\"result\")");
287    }
288
289    #[test]
290    fn test_give_take_money() {
291        let give = ScriptCommand::GiveMoney { amount: 999 };
292        let take = ScriptCommand::TakeMoney { amount: 50 };
293        assert_ne!(give, take);
294        if let ScriptCommand::GiveMoney { amount } = &give {
295            assert_eq!(*amount, 999);
296        } else {
297            panic!("expected GiveMoney");
298        }
299    }
300
301    #[test]
302    fn test_show_hide_scene() {
303        let show = ScriptCommand::ShowScene {
304            scene_name: "shop".into(),
305            layout_json: None,
306        };
307        let hide = ScriptCommand::HideScene {
308            scene_name: "shop".into(),
309        };
310        assert_ne!(show, hide);
311        assert_eq!(
312            show,
313            ScriptCommand::ShowScene {
314                scene_name: "shop".into(),
315                layout_json: None
316            }
317        );
318    }
319
320    #[test]
321    fn test_update_ui_command() {
322        let cmd = ScriptCommand::UpdateUI {
323            scene_name: "bag".into(),
324            data_json: r#"{"gold":500}"#.into(),
325        };
326        assert!(format!("{cmd:?}").contains("bag"));
327        assert!(format!("{cmd:?}").contains("gold"));
328    }
329}