epicinium_server 1.0.12

An asynchronous multiplayer server for the strategy game Epicinium.
Documentation
/*
 * Part of epicinium_server
 * developed by A Bunch of Hacks.
 *
 * Copyright (c) 2018-2021 A Bunch of Hacks
 *
 * This library is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * [authors:]
 * Sander in 't Veld (sander@abunchofhacks.coop)
 */

use crate::common::platform::Platform;
use crate::common::version::Version;
use crate::server::settings::Settings;

use log::*;

use serde_derive::{Deserialize, Serialize};
use serde_json::json;

use anyhow::anyhow;

use tokio::sync::mpsc;
use tokio::time::Duration;

use reqwest as http;

#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Post
{
	GameStarted
	{
		#[serde(rename = "rated")]
		is_rated: bool,

		#[serde(rename = "player1")]
		first_player_username: String,

		#[serde(rename = "player2")]
		second_player_username: String,

		#[serde(rename = "map")]
		map_name: String,

		#[serde(rename = "ruleset")]
		ruleset_name: String,

		#[serde(rename = "time")]
		planning_time_in_seconds_or_zero: u32,
	},
	GameEnded
	{
		#[serde(rename = "rated")]
		is_rated: bool,

		#[serde(rename = "player1")]
		first_player_username: String,

		#[serde(rename = "player1_defeated")]
		is_first_player_defeated: bool,

		#[serde(rename = "player1_score")]
		first_player_score: i32,

		#[serde(rename = "player2")]
		second_player_username: String,

		#[serde(rename = "player2_defeated")]
		is_second_player_defeated: bool,

		#[serde(rename = "player2_score")]
		second_player_score: i32,
	},
	Link
	{
		discord_id: String,
		username: String,
	},
}

pub struct Setup
{
	connection: Option<Connection>,
}

pub fn setup(settings: &Settings) -> Result<Setup, anyhow::Error>
{
	if settings.discordurl.is_some()
	{
		let connection = Connection::start(settings)?;
		Ok(Setup {
			connection: Some(connection),
		})
	}
	else
	{
		Ok(Setup { connection: None })
	}
}

pub async fn run(setup: Setup, mut posts: mpsc::Receiver<Post>)
{
	match setup
	{
		Setup {
			connection: Some(connection),
		} =>
		{
			info!("Connected.");
			while let Some(post) = posts.recv().await
			{
				connection.send(post).await;
			}
			info!("Finished sending posts to Discord.");
		}
		Setup { connection: None } =>
		{
			while let Some(post) = posts.recv().await
			{
				let message = match serde_json::to_string(&post)
				{
					Ok(message) => message,
					Err(error) =>
					{
						error!("Error while jsonifying: {:?}", error);
						debug!("Original post: {:?}", post);
						continue;
					}
				};
				debug!("{}", message);
			}
		}
	}
}

struct Connection
{
	http: http::Client,
	url: http::Url,
}

impl Connection
{
	fn start(settings: &Settings) -> Result<Connection, anyhow::Error>
	{
		let url = settings
			.discordurl
			.as_ref()
			.ok_or_else(|| anyhow!("missing 'discordurl'"))?;
		let mut url = http::Url::parse(url)?;
		url.query_pairs_mut().append_pair("wait", "true");

		let platform = Platform::current();
		let platformstring = serde_plain::to_string(&platform)?;
		let user_agent = format!(
			"epicinium-server/{} ({}; rust)",
			Version::current(),
			platformstring,
		);

		let http = http::Client::builder().user_agent(user_agent).build()?;

		let connection = Connection { http, url };

		Ok(connection)
	}

	async fn send(&self, post: Post)
	{
		let message = match serde_json::to_string(&post)
		{
			Ok(message) => message,
			Err(error) =>
			{
				error!("Error while jsonifying: {:?}", error);
				error!("Original post: {:?}", post);
				return;
			}
		};

		trace!("Sending: {}", message);

		let payload = json!({
			"content": message,
		});

		loop
		{
			match self.try_send(&payload).await
			{
				Ok(Status::Ok) => break,
				Ok(Status::RateLimited { retry_after }) =>
				{
					warn!(
						"We are being rate limited, retrying after {}ms...",
						retry_after.as_millis()
					);
					tokio::time::delay_for(retry_after).await;
				}
				Err(error) =>
				{
					error!("Error: {:#?}", error);
					break;
				}
			}
		}
	}

	async fn try_send(
		&self,
		payload: &serde_json::Value,
	) -> Result<Status, Error>
	{
		let response = self
			.http
			.request(http::Method::POST, self.url.clone())
			.json(payload)
			.send()
			.await?;
		let status = response.status();
		if status == http::StatusCode::TOO_MANY_REQUESTS
		{
			let text = response.text().await?;
			let response: Response = serde_json::from_str(&text)?;
			let retry_after = Duration::from_millis(response.retry_after);
			Ok(Status::RateLimited { retry_after })
		}
		else
		{
			let _response = response.error_for_status()?;
			Ok(Status::Ok)
		}
	}
}

#[derive(Debug)]
enum Status
{
	Ok,
	RateLimited
	{
		retry_after: Duration,
	},
}

#[derive(Debug, Deserialize)]
struct Response
{
	retry_after: u64,
}

#[derive(Debug)]
enum Error
{
	Http(http::Error),
	Json(serde_json::Error),
}

impl From<http::Error> for Error
{
	fn from(error: http::Error) -> Error
	{
		Error::Http(error)
	}
}

impl From<serde_json::Error> for Error
{
	fn from(error: serde_json::Error) -> Error
	{
		Error::Json(error)
	}
}

impl std::fmt::Display for Error
{
	fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result
	{
		match self
		{
			Error::Http(error) => error.fmt(f),
			Error::Json(error) => error.fmt(f),
		}
	}
}