Skip to main content

neuro_sama/
game.rs

1//! A high(er) level API that utilizes the Rust type system for somewhat better ergonomics.
2//!
3//! You should implement the [`Game`] trait to use the [`Api`] trait on your object (which will
4//! call into [`Game`] methods when it receives messages from the API).
5//!
6//! If you require mutable access to your game object, you should use the [`GameMut`] trait
7//! instead - then you can use the [`ApiMut`] trait, which is exactly the same as [`Api`], except
8//! it takes a mutable reference, allowing you to mutate the object. You don't have to implement
9//! both.
10use std::{
11    borrow::Cow,
12    ops::{Deref, DerefMut},
13};
14
15use crate::schema::{self, ClientCommandContents, ServerCommand};
16
17mod glue;
18
19pub use glue::{ActionMetadata, Actions};
20use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec};
21use thiserror::Error;
22
23/// A trait to be implemented by your game to create an [`Api`] object.
24///
25/// You should generally *not* call these methods yourself - instead, call [`Api`] methods, which
26/// will call into your code.
27///
28/// # Example
29///
30/// ```rust,ignore
31/// use schemars::JsonSchema;
32/// use serde::Deserialize;
33/// use neuro_sama::game::{Api, Game};
34///
35/// // Note that the default schema for this will allow any integers from 0 to 255, which isn't
36/// // necessarily what we want. If you want to customize this, you will have to manually implement
37/// // the `JsonSchema` trait.
38/// #[derive(Debug, JsonSchema, Deserialize)]
39/// struct Move {
40///     x: u8,
41///     y: u8,
42/// }
43///
44/// #[derive(Debug, JsonSchema, Deserialize)]
45/// struct Forfeit;
46///
47/// // All of the actions available to Neuro. **The doc comments will be directly passed to Neuro as
48/// // explanation of what the actions do.** The `name` attribute is used to specify the name the
49/// // actions should have for Neuro - the API documentation says:
50/// //
51/// // > This should be a lowercase string, with words separated by underscores or dashes.
52/// //
53/// // By default, for each struct/enum that the command consists of, `title` is set to the struct
54/// // name and `description` is set to the doc comment. However, this library currently strips
55/// // that to make the schema smaller and potentially less confusing. If you think that this can
56/// // actually help make the schema more understandable in some cases, feel free to open an issue.
57/// #[derive(Debug, neuro_sama::derive::Actions)]
58/// enum Action {
59///     /// Make a move, placing your mark on the field at a specified position.
60///     #[name("move")]
61///     Move(Move),
62///     /// Forfeit
63///     #[name("forfeit")]
64///     Forfeit(Forfeit),
65/// }
66///
67/// struct TicTacToe { ... }
68///
69/// impl Game for TicTacToe {
70///     const NAME: &'static str = "Tic Tac Toe";
71///     type Actions<'a> = Action;
72///
73///     fn handle_action<'a>(
74///        &self,
75///        action: Self::Actions<'a>,
76///     ) -> Result<
77///         Option<impl 'static + Into<Cow<'static, str>>>,
78///         Option<impl 'static + Into<Cow<'static, str>>>,
79///     > {
80///         Err(Some("not yet implemented".into()))
81///     }
82///
83///     fn send_command(&self, _message: tungstenite::Message) {
84///         // TODO: send the websocket message
85///     }
86/// }
87///
88/// let game = TicTacToe::new();
89/// // IMPORTANT: call initialize first
90/// game.initialize()?;
91/// game.context("something something you are playing tic tac toe")?;
92/// game.register_actions::<Action>()?;
93///
94/// for message in websocket_channel {
95///     game.notify_message(message)?;
96/// }
97/// ```
98#[neuro_sama_derive::generic_mutability(GameMut)]
99pub trait Game: Sized {
100    /// The game's display name, including any spaces and symbols (e.g. `"Buckshot Roulette"`).
101    const NAME: &'static str;
102
103    /// A enum with all the action types that Neuro can pass to the game.
104    ///
105    /// The `json5` crate is used for handling the input, since the JSON is generated by Neuro.
106    /// To actually create this enum, make an enum over types that implement the [`Action`] trait,
107    /// and make sure the enum tags as seen by `serde` match what [`Action::name()`] returns. This
108    /// is a bit annoying, so for convenience, you can use the `neuro_sama::derive` module.
109    type Actions<'a>: Actions<'a>;
110
111    /// Handle Neuro's action.
112    ///
113    /// # Parameters
114    ///
115    /// - `api` - the API this action came from
116    /// - `action` - the action that Neuro passed to the game.
117    ///
118    /// # Returns
119    ///
120    /// A result with an optional associated message to pass to Neuro. The result should be
121    /// returned as soon as possible, usually before actually executing the action in-game.
122    ///
123    /// # Note
124    ///
125    /// If you return `Err` on a forced action, Neuro will try again. If you don't want that, just
126    /// return `Ok` with an error message.
127    fn handle_action<'a>(
128        &self,
129        action: Self::Actions<'a>,
130    ) -> Result<
131        Option<impl 'static + Into<Cow<'static, str>>>,
132        Option<impl 'static + Into<Cow<'static, str>>>,
133    >;
134
135    /// Called when required by the game to reregister all available actions
136    fn reregister_actions(&self);
137
138    /// You should create or identify graceful shutdown points where the game can be closed gracefully after saving progress. You should store the latest received wants_shutdown value, and if it is true when a graceful shutdown point is reached, you should save the game and quit to main menu, then send back a shutdown ready message. Don't close the game entirely.
139    ///
140    /// # Note
141    ///
142    /// This is part of the game automation API, which will only be used for games that Neuro can launch by herself. As such, most games will not need to implement this.
143    #[cfg(feature = "proposals")]
144    fn graceful_shutdown_wanted(&self, wants_shutdown: bool) {
145        let _ = wants_shutdown;
146    }
147
148    /// This message will be sent when the game needs to be shutdown immediately. You have only a handful of seconds to save as much progress as possible. After you have saved, you can send back a shutdown ready message (don't close the game by yourself).
149    ///
150    /// # Note
151    ///
152    /// This is part of the game automation API, which will only be used for games that Neuro can launch by herself. As such, most games will not need to implement this.
153    #[cfg(feature = "proposals")]
154    fn immediate_shutdown(&self) {}
155
156    /// Send a message to the WebSocket backend. If an error happens, you can handle it by
157    /// attempting to reopen the connection and calling [`Api::initialize`] on the API after a
158    /// reconnect.
159    fn send_command(&self, message: tungstenite::Message);
160}
161
162impl<G: Game, T: Deref<Target = G>> Game for T {
163    const NAME: &'static str = G::NAME;
164    type Actions<'a> = G::Actions<'a>;
165
166    fn handle_action<'a>(
167        &self,
168        action: Self::Actions<'a>,
169    ) -> Result<
170        Option<impl 'static + Into<Cow<'static, str>>>,
171        Option<impl 'static + Into<Cow<'static, str>>>,
172    > {
173        self.deref()
174            .handle_action(action)
175            .map(|x| x.map(Into::into))
176            .map_err(|x| x.map(Into::into))
177    }
178    fn reregister_actions(&self) {
179        self.deref().reregister_actions();
180    }
181    #[cfg(feature = "proposals")]
182    fn graceful_shutdown_wanted(&self, wants_shutdown: bool) {
183        self.deref().graceful_shutdown_wanted(wants_shutdown);
184    }
185    #[cfg(feature = "proposals")]
186    fn immediate_shutdown(&self) {
187        self.deref().immediate_shutdown();
188    }
189    fn send_command(&self, message: tungstenite::Message) {
190        self.deref().send_command(message);
191    }
192}
193
194impl<G: GameMut, T: DerefMut<Target = G>> GameMut for T {
195    const NAME: &'static str = G::NAME;
196    type Actions<'a> = G::Actions<'a>;
197
198    fn handle_action<'a>(
199        &mut self,
200        action: Self::Actions<'a>,
201    ) -> Result<
202        Option<impl 'static + Into<Cow<'static, str>>>,
203        Option<impl 'static + Into<Cow<'static, str>>>,
204    > {
205        self.deref_mut()
206            .handle_action(action)
207            .map(|x| x.map(Into::into))
208            .map_err(|x| x.map(Into::into))
209    }
210    fn reregister_actions(&mut self) {
211        self.deref_mut().reregister_actions();
212    }
213    #[cfg(feature = "proposals")]
214    fn graceful_shutdown_wanted(&mut self, wants_shutdown: bool) {
215        self.deref_mut().graceful_shutdown_wanted(wants_shutdown);
216    }
217    #[cfg(feature = "proposals")]
218    fn immediate_shutdown(&mut self) {
219        self.deref_mut().immediate_shutdown();
220    }
221    fn send_command(&mut self, message: tungstenite::Message) {
222        self.deref_mut().send_command(message);
223    }
224}
225
226/// An error that occured somewhere while sending/receiving a message.
227#[non_exhaustive]
228#[derive(Debug, Error)]
229pub enum Error {
230    /// A JSON error
231    #[error("json error: {0}")]
232    Json(
233        #[from]
234        #[source]
235        serde_json::Error,
236    ),
237}
238
239/// A trait that has to be implemented by actions. It is automatically implemented when you create
240/// an enum for all actions with `#[derive(neuro_sama::derive::Actions)]`.
241///
242/// Note that while there aren't any hard limitations on how complex the JSON schema can be, Neuro
243/// might get confused if it's too complex.
244pub trait Action: schemars::JsonSchema {
245    /// The name of the action, which is its *unique identifier*. This should be a lowercase string, with words separated by underscores or dashes (e.g. `"join_friend_lobby"`, `"use_item"`).
246    fn name() -> &'static str;
247
248    /// A plaintext description of what this action does. **This information will be directly received by Neuro.**
249    fn description() -> &'static str;
250}
251
252fn cleanup_action(action: &mut schema::Action) {
253    fn visit_schema(schema: &mut Schema) {
254        match schema {
255            Schema::Object(obj) => visit_schema_obj(obj),
256            Schema::Bool(_) => {}
257        }
258    }
259
260    fn visit_schema_obj(schema: &mut SchemaObject) {
261        if let Some(meta) = schema.metadata.as_mut() {
262            meta.description = None;
263            meta.title = None;
264        }
265        if let Some(arr) = schema.array.as_mut() {
266            for x in &mut arr.items {
267                match x {
268                    SingleOrVec::Single(schema) => visit_schema(schema),
269                    SingleOrVec::Vec(schemas) => {
270                        for schema in schemas {
271                            visit_schema(schema);
272                        }
273                    }
274                }
275            }
276            for x in arr
277                .contains
278                .iter_mut()
279                .chain(arr.additional_items.iter_mut())
280            {
281                visit_schema(x);
282            }
283        }
284        if let Some(obj) = schema.object.as_mut() {
285            for schema in obj
286                .properties
287                .values_mut()
288                .chain(obj.pattern_properties.values_mut())
289                .chain(
290                    obj.additional_properties
291                        .iter_mut()
292                        .chain(obj.property_names.iter_mut())
293                        .map(|x| &mut **x),
294                )
295            {
296                visit_schema(schema);
297            }
298        }
299        if let Some(sub) = schema.subschemas.as_mut() {
300            for schema in sub
301                .all_of
302                .iter_mut()
303                .chain(sub.any_of.iter_mut())
304                .chain(sub.one_of.iter_mut())
305                .flat_map(|x| x.iter_mut())
306                .chain(
307                    sub.not
308                        .iter_mut()
309                        .chain(sub.if_schema.iter_mut())
310                        .chain(sub.then_schema.iter_mut())
311                        .chain(sub.else_schema.iter_mut())
312                        .map(|x| &mut **x),
313                )
314            {
315                visit_schema(schema);
316            }
317        }
318    }
319    action.schema.meta_schema = None;
320    visit_schema_obj(&mut action.schema.schema);
321    match &action.schema.schema.instance_type {
322        Some(SingleOrVec::Single(x)) if **x == InstanceType::Null => {
323            action.schema.schema.instance_type = None;
324        }
325        _ => {}
326    }
327}
328
329fn send_ws_command<G: Game>(game: &G, cmd: schema::ClientCommandContents) -> Result<(), Error> {
330    let data = crate::to_string(&schema::ClientCommand {
331        command: cmd,
332        game: G::NAME.into(),
333    })?;
334    game.send_command(tungstenite::Message::text(data));
335    Ok(())
336}
337
338fn send_ws_command_mut<G: GameMut>(
339    game: &mut G,
340    cmd: schema::ClientCommandContents,
341) -> Result<(), Error> {
342    let data = crate::to_string(&schema::ClientCommand {
343        command: cmd,
344        game: G::NAME.into(),
345    })?;
346    game.send_command(tungstenite::Message::text(data));
347    Ok(())
348}
349
350impl<T: Game> Api for T {}
351impl<T: GameMut> ApiMut for T {}
352
353/// A sealed trait implemented for all objects that implement [`Game`]. You can use these methods for
354/// talking to the Neuro API. Main points of interest are [`Api::initialize`] (which must be called first)
355/// and [`Api::handle_message`] for handling incoming WebSocket messages.
356#[neuro_sama_derive::generic_mutability(ApiMut, GameMut)]
357pub trait Api: Game {
358    /// Reinitialize the API (sending the `startup` action and reregistering all actions).
359    ///
360    /// **This *must* be called before using any other method from [`Api`]**, and also whenever the
361    /// WebSocket connection is reopened.
362    ///
363    /// Sadly, this isn't enforced in the type system, because the typestate pattern would be quite
364    /// bulky here, and this isn't enforced in runtime because traits don't have any state - so
365    /// please just remember to call it.
366    ///
367    /// A previous version of this crate had a separate struct just for enforcing this being
368    /// called, but not enforcing this at all seems to lead to a better API.
369    fn initialize(&self) -> Result<(), Error> {
370        let ret = send_ws_command(self, ClientCommandContents::Startup);
371        if ret.is_ok() {
372            self.reregister_actions();
373        }
374        ret
375    }
376
377    /// This message can be sent to let Neuro know about something that is happening in game.
378    ///
379    /// # Parameters
380    ///
381    /// - `context` - a plaintext message that describes what is happening in the game. **This information will be directly received by Neuro.**
382    /// - `silent` - if `true`, the message will be added to Neuro's context without prompting her to respond to it. If `false`, Neuro might respond to the message directly, unless she is busy talking to someone else or to chat.
383    fn context(&self, context: impl Into<Cow<'static, str>>, silent: bool) -> Result<(), Error> {
384        send_ws_command(
385            self,
386            ClientCommandContents::Context {
387                message: context.into(),
388                silent,
389            },
390        )
391    }
392
393    /// Register actions.
394    ///
395    /// # Example
396    ///
397    /// ```rust,ignore
398    /// use schemars::JsonSchema;
399    /// use serde::Deserialize;
400    ///
401    /// #[derive(Deserialize, JsonSchema)]
402    /// struct Move {
403    ///     x: u32,
404    ///     y: u32,
405    /// }
406    ///
407    /// #[derive(Deserialize, JsonSchema)]
408    /// struct Shoot;
409    ///
410    /// #[derive(neuro_sama::game::Actions)]
411    /// enum Action {
412    ///     /// Move to a different position
413    ///     #[name = "move"]
414    ///     Move(Move),
415    ///     /// Shoot the enemy
416    ///     #[name = "shoot"]
417    ///     Shoot(Shoot),
418    /// }
419    ///
420    /// api.register_actions::<(Move, Shoot)>();
421    /// // or
422    /// api.register_actions::<Action>();
423    ///
424    /// // later
425    /// api.unregister_actions::<(Move, Shoot)>();
426    /// // or
427    /// api.unregister_actions::<Move>();
428    /// ```
429    fn register_actions<A: ActionMetadata>(&self) -> Result<(), Error> {
430        self.register_actions_raw(A::actions())
431    }
432
433    /// Directly call `actions/register`. You should typically use [`Api::register_actions`] instead.
434    fn register_actions_raw(&self, mut actions: Vec<schema::Action>) -> Result<(), Error> {
435        for action in &mut actions {
436            cleanup_action(action);
437        }
438        send_ws_command(self, ClientCommandContents::RegisterActions { actions })
439    }
440
441    /// Unregister actions. See [`Api::register_actions`] for example use.
442    fn unregister_actions<A: ActionMetadata>(&self) -> Result<(), Error> {
443        self.unregister_actions_raw(A::names())
444    }
445
446    /// Directly call `actions/unregister`. You should typically use [`Api::unregister_actions`] instead.
447    fn unregister_actions_raw(&self, action_names: Vec<Cow<'static, str>>) -> Result<(), Error> {
448        send_ws_command(
449            self,
450            ClientCommandContents::UnregisterActions { action_names },
451        )
452    }
453
454    /// Handle a new websocket message. Note that this only handles `Text` and `Binary` messages,
455    /// the rest are silently ignored.
456    fn handle_message(&self, message: tungstenite::Message) -> Result<(), Error> {
457        let message = match message {
458            tungstenite::Message::Text(s) => serde_json::from_str(&s)?,
459            tungstenite::Message::Binary(b) => serde_json::from_slice(&b)?,
460            _ => return Ok(()),
461        };
462        let (id, res) = match message {
463            ServerCommand::Action { id, name, data } => {
464                let res = data.as_ref().filter(|x| !x.trim().is_empty()).map_or_else(
465                    || {
466                        <Self::Actions<'_> as Actions>::deserialize(
467                            &name,
468                            serde::de::value::UnitDeserializer::new(),
469                        )
470                    },
471                    |data| {
472                        json5::Deserializer::from_str(data)
473                            .and_then(|mut de| {
474                                <Self::Actions<'_> as Actions>::deserialize(&name, &mut de)
475                            })
476                            .or_else(|err| {
477                                let mut data = data.clone();
478                                data.retain(|x| !x.is_whitespace());
479                                if data.is_empty() || data == "{}" {
480                                    <Self::Actions<'_> as Actions>::deserialize(
481                                        &name,
482                                        serde::de::value::UnitDeserializer::new(),
483                                    )
484                                    .map_err(|_: json5::Error| err)
485                                } else {
486                                    Err(err)
487                                }
488                            })
489                    },
490                );
491                let data = match res {
492                    Ok(data) => data,
493                    Err(err) => {
494                        return send_ws_command(
495                            self,
496                            ClientCommandContents::ActionResult {
497                                id,
498                                success: false,
499                                message: Some(
500                                    ("Failed to deserialize Neuro-provided action data: "
501                                        .to_owned()
502                                        + &err.to_string())
503                                        .into(),
504                                ),
505                            },
506                        );
507                    }
508                };
509                (id, self.handle_action(data))
510            }
511            #[cfg(feature = "proposals")]
512            ServerCommand::ReregisterAllActions => {
513                self.reregister_actions();
514                return Ok(());
515            }
516            #[cfg(feature = "proposals")]
517            ServerCommand::GracefulShutdown { wants_shutdown } => {
518                self.graceful_shutdown_wanted(wants_shutdown);
519                return Ok(());
520            }
521            #[cfg(feature = "proposals")]
522            ServerCommand::ImmediateShutdown => {
523                self.immediate_shutdown();
524                return Ok(());
525            }
526        };
527        let res = match res {
528            Ok(msg) => ClientCommandContents::ActionResult {
529                id,
530                success: true,
531                message: msg.map(Into::into),
532            },
533            Err(msg) => ClientCommandContents::ActionResult {
534                id,
535                success: false,
536                message: msg.map(Into::into),
537            },
538        };
539        send_ws_command(self, res)
540    }
541
542    /// Tell Neuro to execute one of the listed actions as soon as possible. Note that this might take a bit if she is already talking.
543    ///
544    /// # Parameters
545    ///
546    /// - `query` - a plaintext message that tells Neuro what she is currently supposed to be doing (e.g. `"It is now your turn. Please perform an action. If you want to use any items, you should use them before picking up the shotgun."`). **This information will be directly received by Neuro.**
547    /// - `action_names` - the names of the actions that Neuro should choose from.
548    ///
549    /// # Returns
550    ///
551    /// A builder object that can be used to configure the request further. After you've configured
552    /// it, please send the request using the `.send()` method on the builder.
553    #[must_use]
554    fn force_actions<T: ActionMetadata>(
555        &self,
556        query: Cow<'static, str>,
557    ) -> ForceActionsBuilder<Self> {
558        self.force_actions_raw(query, T::names())
559    }
560
561    /// A version of [`Api::force_actions`] that uses raw action names instead of type parameters.
562    #[must_use]
563    fn force_actions_raw(
564        &self,
565        query: Cow<'static, str>,
566        action_names: Vec<Cow<'static, str>>,
567    ) -> ForceActionsBuilder<Self> {
568        ForceActionsBuilder {
569            api: self,
570            state: None,
571            query,
572            ephemeral_context: None,
573            action_names,
574        }
575    }
576}
577
578/// A builder object for sending an `actions/force` message.
579pub struct ForceActionsBuilder<'a, G: Api> {
580    api: &'a G,
581    state: Option<Cow<'static, str>>,
582    query: Cow<'static, str>,
583    ephemeral_context: Option<bool>,
584    action_names: Vec<Cow<'static, str>>,
585}
586
587/// A mutable version of [`ForceActionsBuilder`]. See [`ForceActionsBuilder`] docs for more info.
588pub struct ForceActionsBuilderMut<'a, G: ApiMut> {
589    api: &'a mut G,
590    state: Option<Cow<'static, str>>,
591    query: Cow<'static, str>,
592    ephemeral_context: Option<bool>,
593    action_names: Vec<Cow<'static, str>>,
594}
595
596#[neuro_sama_derive::generic_mutability(ForceActionsBuilderMut, ApiMut)]
597impl<'a, G: Api> ForceActionsBuilder<'a, G> {
598    /// If `false`, the context provided in the `state` and `query` parameters will be remembered by Neuro after the actions force is compelted. If `true`, Neuro will only remember it for the duration of the actions force.
599    #[must_use]
600    pub fn with_ephemeral_context(mut self, ephemeral_context: bool) -> Self {
601        self.ephemeral_context = Some(ephemeral_context);
602        self
603    }
604
605    /// An arbitrary string that describes the current state of the game. This can be plaintext, JSON, Markdown, or any other format. **This information will be directly received by Neuro.**
606    #[must_use]
607    pub fn with_state(mut self, state: impl Into<Cow<'static, str>>) -> Self {
608        self.state = Some(state.into());
609        self
610    }
611
612    /// Send the WebSocket message to the server.
613    pub fn send(self) -> Result<(), Error> {
614        send_ws_command(
615            self.api,
616            schema::ClientCommandContents::ForceActions {
617                state: self.state,
618                query: self.query,
619                ephemeral_context: self.ephemeral_context,
620                action_names: self.action_names,
621            },
622        )
623    }
624}
625
626#[cfg(test)]
627mod test {
628    use serde::Deserialize;
629
630    use crate::{
631        self as neuro_sama,
632        game::{cleanup_action, ActionMetadata},
633    };
634
635    /// Move action
636    #[derive(Debug, schemars::JsonSchema, Deserialize, PartialEq)]
637    struct Move {
638        x: u32,
639        y: u32,
640    }
641
642    /// Shoot action
643    #[derive(Debug, schemars::JsonSchema, Deserialize, PartialEq)]
644    struct Shoot;
645
646    #[derive(crate::derive::Actions, Debug, PartialEq)]
647    enum Action {
648        /// test1
649        #[name = "move"]
650        Move(Move),
651        /// test2
652        #[name = "shoot"]
653        Shoot(Shoot),
654    }
655
656    #[test]
657    fn test() {
658        use super::Actions;
659        let mut deser = serde_json::Deserializer::from_str(r#"{"x":5,"y":6}"#);
660        let action = <Action as Actions>::deserialize("move", &mut deser).unwrap();
661        assert_eq!(action, Action::Move(Move { x: 5, y: 6 }));
662        let mut deser = json5::Deserializer::from_str(r#"null"#).unwrap();
663        let action = <Action as Actions>::deserialize("shoot", &mut deser).unwrap();
664        assert_eq!(action, Action::Shoot(Shoot));
665        let mut actions = <Action as ActionMetadata>::actions();
666        for action in &mut actions {
667            cleanup_action(action);
668        }
669        #[cfg(feature = "strip-trailing-zeroes")]
670        assert_eq!(
671            crate::to_string(&actions).unwrap(),
672            r#"[
673              {
674                "name": "move",
675                "description": "test 1",
676                "schema": {
677                  "type": "object",
678                  "required": [ "x", "y" ],
679                  "properties": {
680                    "x": { "type": "integer", "format": "uint32", "minimum": 0 },
681                    "y": { "type": "integer", "format": "uint32", "minimum": 0 }
682                  }
683                }
684              },
685              {
686                "name": "shoot",
687                "description": "test 2",
688                "schema": {
689                  "type": "null"
690                }
691              }
692            ]"#
693            .to_string()
694            .replace(|x| x == ' ' || x == '\n', "")
695        );
696        #[cfg(not(feature = "strip-trailing-zeroes"))]
697        assert_eq!(
698            crate::to_string(&actions).unwrap(),
699            r#"[
700              {
701                "name": "move",
702                "description": "test 1",
703                "schema": {
704                  "type": "object",
705                  "required": [ "x", "y" ],
706                  "properties": {
707                    "x": { "type": "integer", "format": "uint32", "minimum": 0.0 },
708                    "y": { "type": "integer", "format": "uint32", "minimum": 0.0 }
709                  }
710                }
711              },
712              {
713                "name": "shoot",
714                "description": "test 2",
715                "schema": {
716                  "type": "null"
717                }
718              }
719            ]"#
720            .to_string()
721            .replace(|x| x == ' ' || x == '\n', "")
722        );
723    }
724}