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}