neuro-sama 0.1.0

A crate that implements the Neuro-sama game API
Documentation
//! A high(er) level API that utilizes the Rust type system for somewhat better ergonomics.
use std::{borrow::Cow, sync::Arc};

use crate::schema::{self, ClientCommandContents, ServerCommand};

mod glue;

pub use glue::{ActionMetadata, Actions};

/// A trait to be implemented by your game to create an [`Api`] object.
///
/// # Example
///
/// ```rust,ignore
/// use schemars::JsonSchema;
/// use serde::Deserialize;
///
/// // Note that the default schema for this will allow any integers from 0 to 255, which isn't
/// // necessarily what we want. If you want to customize this, you will have to manually implement
/// // the `JsonSchema` trait.
/// #[derive(Debug, JsonSchema, Deserialize)]
/// struct Move {
///     x: u8,
///     y: u8,
/// }
///
/// #[derive(Debug, JsonSchema, Deserialize)]
/// struct Forfeit;
///
/// // All of the actions available to Neuro. **The doc comments will be directly passed to Neuro as
/// // explanation of what the actions do.** The `name` attribute is used to specify the name the
/// // actions should have for Neuro - the API documentation says:
/// //
/// // > This should be a lowercase string, with words separated by underscores or dashes.
/// #[derive(Debug, neuro_sama::derive::Actions)]
/// enum Action {
///     /// Make a move, placing your mark on the field at a specified position.
///     #[name("move")]
///     Move(Move),
///     /// Forfeit
///     #[name("forfeit")]
///     Forfeit(Forfeit),
/// }
///
/// struct TicTacToe { ... }
///
/// impl Game for TicTacToe {
///     const NAME: &'static str = "Tic Tac Toe";
///     type Actions<'a> = Action;
///
///     fn handle_action<'a>(
///        &self,
///        action: Self::Actions<'a>,
///     ) -> Result<
///         Option<impl 'static + Into<Cow<'static, str>>>,
///         Option<impl 'static + Into<Cow<'static, str>>>,
///     > {
///         Err(Some("not yet implemented".into()))
///     }
///
///     fn send_command(&self, _message: tungstenite::Message) {
///         // TODO: send the websocket message
///     }
/// }
///
/// let game = Arc::new(TicTacToe::new());
/// let api = neuro_sama::game::Api::new(game)?;
/// api.context("something something you are playing tic tac toe")?;
/// api.register_actions::<Action>()?;
///
/// for message in websocket_channel {
///     api.notify_message(message)?;
/// }
/// ```
pub trait Game: Sized {
    /// The game's display name.
    const NAME: &'static str;
    /// A enum with all the action types that Neuro can pass to the game.
    ///
    /// The `json5` crate is used for handling the input, since the JSON is generated by Neuro.
    /// To actually create this enum, make an enum over types that implement the [`Action`] trait,
    /// and make sure the enum tags as seen by `serde` match what [`Action::name()`] returns. This
    /// is a bit annoying, so for convenience, you can use the `derive` module.
    type Actions<'a>: Actions<'a>;
    /// Handle Neuro's action.
    ///
    /// # Parameters
    ///
    /// - `action` - the action that Neuro passed to the game.
    ///
    /// # Returns
    ///
    /// A result with an optional associated message to pass to Neuro.
    ///
    /// # Note
    ///
    /// If you return `Err` on a forced action, Neuro will try again. If you don't want that, just
    /// return `Ok` even if you're returning an error message.
    fn handle_action<'a>(
        &self,
        action: Self::Actions<'a>,
    ) -> Result<
        Option<impl 'static + Into<Cow<'static, str>>>,
        Option<impl 'static + Into<Cow<'static, str>>>,
    >;
    /// Send a message to the WebSocket backend. If an error happens - I don't care, record it and
    /// try to reconnect or something.
    fn send_command(&self, message: tungstenite::Message);
}

/// API object for... accessing the API.
pub struct Api<G: Game> {
    game: Arc<G>,
}

pub trait Action: schemars::JsonSchema {
    fn name() -> &'static str;
    fn description() -> &'static str;
}

impl<G: Game> Api<G> {
    /// Create a new API object. This takes an `Arc` of your game, this forces it to not be mutable
    /// but that's fully intended because asynchronous action handling is theoretically allowed,
    /// since each action has a separate ID which means multiple parallel actions can happen at the
    /// same time.
    pub fn new(game: Arc<G>) -> serde_json::Result<Self> {
        let ret = Self { game };
        ret.reinitialize()?;
        Ok(ret)
    }
    /// Reinitialize the API (sending the `startup` action).
    ///
    /// This message clears all previously registered actions for this game and does initial setup, and as such should be the very first message that you send.
    pub fn reinitialize(&self) -> serde_json::Result<()> {
        self.send_command(ClientCommandContents::Startup)
    }
    /// This message can be sent to let Neuro know about something that is happening in game.
    pub fn context(
        &self,
        context: impl Into<Cow<'static, str>>,
        silent: bool,
    ) -> serde_json::Result<()> {
        self.send_command(ClientCommandContents::Context {
            message: context.into(),
            silent,
        })
    }
    /// Register actions.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// use schemars::JsonSchema;
    /// use serde::Deserialize;
    ///
    /// #[derive(Deserialize, JsonSchema)]
    /// struct Move {
    ///     x: u32,
    ///     y: u32,
    /// }
    ///
    /// #[derive(Deserialize, JsonSchema)]
    /// struct Shoot;
    ///
    /// #[derive(neuro_sama::game::Actions)]
    /// enum Action {
    ///     /// Move to a different position
    ///     #[name = "move"]
    ///     Move(Move),
    ///     /// Shoot the enemy
    ///     #[name = "shoot"]
    ///     Shoot(Shoot),
    /// }
    ///
    /// api.register_actions::<(Move, Shoot)>();
    /// // or
    /// api.register_actions::<Action>();
    ///
    /// // later
    /// api.unregister_actions::<(Move, Shoot)>();
    /// // or
    /// api.unregister_actions::<Move>();
    /// ```
    pub fn register_actions<A: ActionMetadata>(&self) -> serde_json::Result<()> {
        self.register_actions_raw(A::actions())
    }
    /// Unregister actions. See `register_actions` for example use.
    pub fn unregister_actions<A: ActionMetadata>(&self) -> serde_json::Result<()> {
        self.unregister_actions_raw(A::names())
    }
    /// Directly call `actions/unregister`. You should typically use `unregister_actions` instead.
    pub fn unregister_actions_raw(
        &self,
        action_names: Vec<Cow<'static, str>>,
    ) -> serde_json::Result<()> {
        self.send_command(ClientCommandContents::UnregisterActions { action_names })
    }
    /// Directly call `actions/register`. You should typically use `register_actions` instead.
    pub fn register_actions_raw(&self, actions: Vec<schema::Action>) -> serde_json::Result<()> {
        self.send_command(ClientCommandContents::RegisterActions { actions })
    }
    fn send_command(&self, cmd: schema::ClientCommandContents) -> serde_json::Result<()> {
        let data = serde_json::to_string(&schema::ClientCommand {
            command: cmd,
            game: G::NAME.into(),
        })?;
        self.game.send_command(tungstenite::Message::text(data));
        Ok(())
    }
    /// Notify the API object of a new websocket message. Note that this only handles `Text` and
    /// `Binary` messages, the rest are silently ignored.
    pub fn notify_message(&self, message: tungstenite::Message) -> serde_json::Result<()> {
        let message = match message {
            tungstenite::Message::Text(s) => serde_json::from_str(&s)?,
            tungstenite::Message::Binary(b) => serde_json::from_slice(&b)?,
            _ => return Ok(()),
        };
        let (id, res) = match message {
            ServerCommand::Action { id, name, data } => {
                let res = if let Some(data) = data.as_ref().filter(|x| !x.is_empty()) {
                    json5::Deserializer::from_str(data)
                        .and_then(|mut de| <G::Actions<'_> as Actions>::deserialize(&name, &mut de))
                } else {
                    <G::Actions<'_> as Actions>::deserialize(
                        &name,
                        serde::de::value::UnitDeserializer::new(),
                    )
                };
                let data = match res {
                    Ok(data) => data,
                    Err(err) => {
                        return self.send_command(ClientCommandContents::ActionResult {
                            id,
                            success: false,
                            message: Some(
                                ("Failed to deserialize Neuro-provided action data: ".to_owned()
                                    + &err.to_string())
                                    .into(),
                            ),
                        });
                    }
                };
                (id, self.game.handle_action(data))
            }
        };
        let res = match res {
            Ok(msg) => ClientCommandContents::ActionResult {
                id,
                success: true,
                message: msg.map(Into::into),
            },
            Err(msg) => ClientCommandContents::ActionResult {
                id,
                success: false,
                message: msg.map(Into::into),
            },
        };
        self.send_command(res)
    }
    /// 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.
    ///
    /// # Parameters
    ///
    /// - `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.**
    /// - `action_names` - The names of the actions that Neuro should choose from.
    ///
    /// # Returns
    ///
    /// A builder object that can be used to configure the request further. After you've configured
    /// it, please send the request using the `.send()` method on the builder.
    #[must_use]
    pub fn force_actions<S: Into<Cow<'static, str>>>(
        &self,
        query: impl Into<Cow<'static, str>>,
        action_names: impl IntoIterator<Item = S>,
    ) -> ForceActionsBuilder<G> {
        ForceActionsBuilder {
            api: self,
            state: None,
            query: query.into(),
            ephemeral_context: None,
            action_names: action_names.into_iter().map(Into::into).collect(),
        }
    }
}

/// A builder object for sending an `actions/force` message.
pub struct ForceActionsBuilder<'a, G: Game> {
    api: &'a Api<G>,
    state: Option<Cow<'static, str>>,
    query: Cow<'static, str>,
    ephemeral_context: Option<bool>,
    action_names: Vec<Cow<'static, str>>,
}

impl<'a, G: Game> ForceActionsBuilder<'a, G> {
    /// 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.
    pub fn with_ephemeral_context(mut self, ephemeral_context: bool) -> Self {
        self.ephemeral_context = Some(ephemeral_context);
        self
    }
    /// 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.**
    pub fn with_state(mut self, state: impl Into<Cow<'static, str>>) -> Self {
        self.state = Some(state.into());
        self
    }
    /// Send the WebSocket message to the server.
    pub fn send(self) -> serde_json::Result<()> {
        self.api
            .send_command(schema::ClientCommandContents::ForceActions {
                state: self.state,
                query: self.query,
                ephemeral_context: self.ephemeral_context,
                action_names: self.action_names,
            })
    }
}

#[cfg(test)]
mod test {
    use serde::Deserialize;

    use crate::{self as neuro_sama, game::ActionMetadata};

    #[derive(Debug, schemars::JsonSchema, Deserialize, PartialEq)]
    struct Move {
        x: u32,
        y: u32,
    }

    #[derive(Debug, schemars::JsonSchema, Deserialize, PartialEq)]
    struct Shoot;

    #[derive(crate::derive::Actions, Debug, PartialEq)]
    enum Action {
        /// test1
        #[name = "move"]
        Move(Move),
        /// test2
        #[name = "shoot"]
        Shoot(Shoot),
    }

    #[test]
    fn test() {
        use super::Actions;
        let mut deser = serde_json::Deserializer::from_str(r#"{"x":5,"y":6}"#);
        let action = <Action as Actions>::deserialize("move", &mut deser).unwrap();
        assert_eq!(action, Action::Move(Move { x: 5, y: 6 }));
        let mut deser = json5::Deserializer::from_str(r#"null"#).unwrap();
        let action = <Action as Actions>::deserialize("shoot", &mut deser).unwrap();
        assert_eq!(action, Action::Shoot(Shoot));
        assert_eq!(
            serde_json::to_string(&<Action as ActionMetadata>::actions()).unwrap(),
            r#"[
              {
                "name": "move",
                "description": "test 1",
                "schema": {
                  "$schema": "http://json-schema.org/draft-07/schema#",
                  "title": "Move",
                  "type": "object",
                  "required": [ "x", "y" ],
                  "properties": {
                    "x": { "type": "integer", "format": "uint32", "minimum": 0.0 },
                    "y": { "type": "integer", "format": "uint32", "minimum": 0.0 }
                  }
                }
              },
              {
                "name": "shoot",
                "description": "test 2",
                "schema": {
                  "$schema": "http://json-schema.org/draft-07/schema#",
                  "title": "Shoot",
                  "type": "null"
                }
              }
            ]"#
            .to_string()
            .replace(|x| x == ' ' || x == '\n', "")
        );
    }
}