use std::{borrow::Cow, sync::Arc};
use crate::schema::{self, ClientCommandContents, ServerCommand};
mod glue;
pub use glue::{ActionMetadata, Actions};
use schemars::schema::{Schema, SchemaObject, SingleOrVec};
use thiserror::Error;
pub trait Game: Sized {
const NAME: &'static str;
type Actions<'a>: Actions<'a>;
fn handle_action<'a>(
&self,
api: &Api<Self>,
action: Self::Actions<'a>,
) -> Result<
Option<impl 'static + Into<Cow<'static, str>>>,
Option<impl 'static + Into<Cow<'static, str>>>,
>;
fn reregister_actions(&self, api: &Api<Self>);
#[cfg(feature = "proposals")]
fn graceful_shutdown_wanted(&self, api: &Api<Self>, wants_shutdown: bool) {
let _ = (api, wants_shutdown);
}
#[cfg(feature = "proposals")]
fn immediate_shutdown(&self, api: &Api<Self>) {
let _ = api;
}
fn send_command(&self, api: &Api<Self>, message: tungstenite::Message);
}
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum Error {
#[error("json error: {0}")]
Json(
#[from]
#[source]
serde_json::Error,
),
}
#[derive(Debug)]
pub struct Api<G: Game> {
game: Arc<G>,
}
impl<G: Game> Clone for Api<G> {
fn clone(&self) -> Self {
Self {
game: self.game.clone(),
}
}
}
pub trait Action: schemars::JsonSchema {
fn name() -> &'static str;
fn description() -> &'static str;
}
fn cleanup_action(action: &mut schema::Action) {
fn visit_schema(schema: &mut Schema) {
match schema {
Schema::Object(obj) => visit_schema_obj(obj),
Schema::Bool(_) => {}
}
}
fn visit_schema_obj(schema: &mut SchemaObject) {
if let Some(meta) = schema.metadata.as_mut() {
meta.description = None;
meta.title = None;
}
if let Some(arr) = schema.array.as_mut() {
for x in arr.items.iter_mut() {
match x {
SingleOrVec::Single(schema) => visit_schema(schema),
SingleOrVec::Vec(schemas) => {
for schema in schemas {
visit_schema(schema);
}
}
}
}
for x in arr
.contains
.iter_mut()
.chain(arr.additional_items.iter_mut())
{
visit_schema(x);
}
}
if let Some(obj) = schema.object.as_mut() {
for schema in obj
.properties
.values_mut()
.chain(obj.pattern_properties.values_mut())
.chain(
obj.additional_properties
.iter_mut()
.chain(obj.property_names.iter_mut())
.map(|x| &mut **x),
)
{
visit_schema(schema);
}
}
if let Some(sub) = schema.subschemas.as_mut() {
for schema in sub
.all_of
.iter_mut()
.chain(sub.any_of.iter_mut())
.chain(sub.one_of.iter_mut())
.flat_map(|x| x.iter_mut())
.chain(
sub.not
.iter_mut()
.chain(sub.if_schema.iter_mut())
.chain(sub.then_schema.iter_mut())
.chain(sub.else_schema.iter_mut())
.map(|x| &mut **x),
)
{
visit_schema(schema);
}
}
}
action.schema.meta_schema = None;
visit_schema_obj(&mut action.schema.schema);
}
impl<G: Game> Api<G> {
pub fn new(game: Arc<G>) -> Result<Self, Error> {
let ret = Self { game };
ret.reinitialize()?;
Ok(ret)
}
pub fn reinitialize(&self) -> Result<(), Error> {
let ret = self.send_command(ClientCommandContents::Startup);
if ret.is_ok() {
self.game.reregister_actions(self);
}
ret
}
pub fn context(
&self,
context: impl Into<Cow<'static, str>>,
silent: bool,
) -> Result<(), Error> {
self.send_command(ClientCommandContents::Context {
message: context.into(),
silent,
})
}
pub fn register_actions<A: ActionMetadata>(&self) -> Result<(), Error> {
let mut actions = A::actions();
for action in &mut actions {
cleanup_action(action);
}
self.register_actions_raw(actions)
}
pub fn unregister_actions<A: ActionMetadata>(&self) -> Result<(), Error> {
self.unregister_actions_raw(A::names())
}
pub fn unregister_actions_raw(
&self,
action_names: Vec<Cow<'static, str>>,
) -> Result<(), Error> {
self.send_command(ClientCommandContents::UnregisterActions { action_names })
}
pub fn register_actions_raw(&self, actions: Vec<schema::Action>) -> Result<(), Error> {
self.send_command(ClientCommandContents::RegisterActions { actions })
}
fn send_command(&self, cmd: schema::ClientCommandContents) -> Result<(), Error> {
let data = serde_json::to_string(&schema::ClientCommand {
command: cmd,
game: G::NAME.into(),
})?;
self.game
.send_command(self, tungstenite::Message::text(data));
Ok(())
}
pub fn notify_message(&self, message: tungstenite::Message) -> Result<(), Error> {
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(self, data))
}
#[cfg(feature = "proposals")]
ServerCommand::ReregisterAllActions => {
self.game.reregister_actions(self);
return Ok(());
}
#[cfg(feature = "proposals")]
ServerCommand::GracefulShutdown { wants_shutdown } => {
self.game.graceful_shutdown_wanted(self, wants_shutdown);
return Ok(());
}
#[cfg(feature = "proposals")]
ServerCommand::ImmediateShutdown => {
self.game.immediate_shutdown(self);
return Ok(());
}
};
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)
}
#[must_use]
pub fn force_actions<T: ActionMetadata>(
&self,
query: Cow<'static, str>,
) -> ForceActionsBuilder<G> {
self.force_actions_raw(query, T::names())
}
#[must_use]
pub fn force_actions_raw(
&self,
query: Cow<'static, str>,
action_names: Vec<Cow<'static, str>>,
) -> ForceActionsBuilder<G> {
ForceActionsBuilder {
api: self,
state: None,
query,
ephemeral_context: None,
action_names,
}
}
}
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> {
pub fn with_ephemeral_context(mut self, ephemeral_context: bool) -> Self {
self.ephemeral_context = Some(ephemeral_context);
self
}
pub fn with_state(mut self, state: impl Into<Cow<'static, str>>) -> Self {
self.state = Some(state.into());
self
}
pub fn send(self) -> Result<(), Error> {
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::{cleanup_action, 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 {
#[name = "move"]
Move(Move),
#[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));
let mut actions = <Action as ActionMetadata>::actions();
for action in &mut actions {
cleanup_action(action);
}
assert_eq!(
serde_json::to_string(&actions).unwrap(),
r#"[
{
"name": "move",
"description": "test 1",
"schema": {
"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": {
"type": "null"
}
}
]"#
.to_string()
.replace(|x| x == ' ' || x == '\n', "")
);
}
}