feat: add Ark: Survival Ascended support (#197)

* feat: add initial epic client auth call

* fix: working client auth

* feat: unfinished initial EOS query

* first successful query

* first successful server query

* run fmt

* be a bit more detailed about servers

* properly run fmt for sure this time fr fr

* port of what node gamedig has done

* feat: remove query_raw_values to query_raw

* feat: add raw field to epic response

* feat: pass SocketAddr to epic

* feat: remove unused pub access to internal only struct

* feat: add initial generic impl

* fix: possibly conditional comp

* feat: add epic to the protocol list

* feat: add version and add epic to RESPONSES.md

* feat: add asa to definitions

* feat: add initial protocol macros

* feat: conditional serde ser and des

* fix: cfg serde stuff

* fix: epic macro warn dead code

* partial feature gate epic to tls

* fix: remove asa from game definitions
This commit is contained in:
CosminPerRam 2024-04-21 18:53:33 +03:00 committed by GitHub
parent 1620ba36b8
commit 45ffa53de3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 434 additions and 81 deletions

View file

@ -0,0 +1,58 @@
/// The implementation.
pub mod protocol;
/// All types used by the implementation.
pub mod types;
pub use protocol::*;
pub use types::*;
/// Generate a module containing a query function for an epic (EOS) game.
///
/// * `mod_name` - The name to be given to the game module (see ID naming
/// conventions in CONTRIBUTING.md).
/// * `pretty_name` - The full name of the game, will be used as the
/// documentation for the created module.
/// * `steam_app`, `default_port` - Passed through to [game_query_fn].
#[cfg(feature = "games")]
macro_rules! game_query_mod {
($mod_name: ident, $pretty_name: expr, $default_port: literal, $credentials: expr) => {
#[doc = $pretty_name]
pub mod $mod_name {
use crate::protocols::epic::Credentials;
crate::protocols::epic::game_query_fn!($pretty_name, $default_port, $credentials);
}
};
}
#[cfg(feature = "games")]
pub(crate) use game_query_mod;
/// Generate a query function for an epic (EOS) game.
///
/// * `default_port` - The default port the game uses.
/// * `credentials` - Credentials to access EOS.
#[cfg(feature = "games")]
macro_rules! game_query_fn {
($pretty_name: expr, $default_port: literal, $credentials: expr) => {
crate::protocols::epic::game_query_fn! {@gen $default_port, concat!(
"Make a Epic query for ", $pretty_name, ".\n\n",
"If port is `None`, then the default port (", stringify!($default_port), ") will be used."), $credentials}
};
(@gen $default_port: literal, $doc: expr, $credentials: expr) => {
#[doc = $doc]
pub fn query(
address: &std::net::IpAddr,
port: Option<u16>,
) -> crate::GDResult<crate::protocols::epic::Response> {
crate::protocols::epic::query(
$credentials,
&std::net::SocketAddr::new(*address, port.unwrap_or($default_port)),
)
}
};
}
#[cfg(feature = "games")]
pub(crate) use game_query_fn;

View file

@ -0,0 +1,181 @@
use crate::http::HttpClient;
use crate::protocols::epic::Response;
use crate::GDErrorKind::{JsonParse, PacketBad};
use crate::{GDResult, TimeoutSettings};
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use serde::Deserialize;
#[cfg(feature = "serde")]
use serde::Serialize;
use serde_json::Value;
use std::net::SocketAddr;
const EPIC_API_ENDPOINT: &'static str = "https://api.epicgames.dev";
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credentials {
#[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))]
pub deployment: &'static str,
#[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))]
pub id: &'static str,
#[cfg_attr(feature = "serde", serde(skip_deserializing, skip_serializing))]
pub secret: &'static str,
pub auth_by_external: bool,
}
pub struct EpicProtocol {
client: HttpClient,
credentials: Credentials,
}
#[derive(Deserialize)]
struct ClientTokenResponse {
access_token: String,
}
#[derive(Deserialize)]
struct QueryResponse {
sessions: Value,
}
macro_rules! extract_optional_field {
($value:expr, $fields:expr, $map_func:expr) => {
$fields
.iter()
.fold(Some(&$value), |acc, &key| acc.and_then(|val| val.get(key)))
.map($map_func)
.flatten()
};
}
macro_rules! extract_field {
($value:expr, $fields:expr, $map_func:expr) => {
extract_optional_field!($value, $fields, $map_func)
.ok_or(PacketBad.context("Field is missing or is not parsable."))?
};
}
impl EpicProtocol {
pub fn new(credentials: Credentials, timeout_settings: TimeoutSettings) -> GDResult<Self> {
Ok(Self {
client: HttpClient::from_url(EPIC_API_ENDPOINT, &Some(timeout_settings), None)?,
credentials,
})
}
pub fn auth_by_external(&self) -> GDResult<String> { Ok(String::new()) }
pub fn auth_by_client(&mut self) -> GDResult<String> {
let body = [
("grant_type", "client_credentials"),
("deployment_id", &self.credentials.deployment),
];
let auth_format = format!("{}:{}", self.credentials.id, self.credentials.secret);
let auth_base = BASE64_STANDARD.encode(auth_format);
let auth = format!("Basic {}", auth_base.as_str());
let authorization = auth.as_str();
let headers = [
("Authorization", authorization),
("Content-Type", "application/x-www-form-urlencoded"),
];
let response =
self.client
.post_json_with_form::<ClientTokenResponse>("/auth/v1/oauth/token", Some(&headers), &body)?;
Ok(response.access_token)
}
pub fn query_raw(&mut self, address: &SocketAddr) -> GDResult<Value> {
let port = address.port();
let address = address.ip().to_string();
let body = format!(
"{{\"criteria\":[{{\"key\":\"attributes.ADDRESS_s\",\"op\":\"EQUAL\",\"value\":\"{}\"}}]}}",
address
);
let body = serde_json::from_str::<Value>(body.as_str()).map_err(|e| JsonParse.context(e))?;
let token = if self.credentials.auth_by_external {
self.auth_by_external()?
} else {
self.auth_by_client()?
};
let authorization = format!("Bearer {}", token);
let headers = [
("Content-Type", "application/json"),
("Accept", "application/json"),
("Authorization", authorization.as_str()),
];
let url = format!("/matchmaking/v1/{}/filter", self.credentials.deployment);
let response: QueryResponse = self.client.post_json(url.as_str(), Some(&headers), body)?;
if let Value::Array(sessions) = response.sessions {
if sessions.is_empty() {
return Err(PacketBad.context("No servers provided."));
}
for session in sessions.into_iter() {
let attributes = session
.get("attributes")
.ok_or(PacketBad.context("Expected attributes field missing in sessions."))?;
if attributes
.get("ADDRESSBOUND_s")
.and_then(Value::as_str)
.map_or(false, |v| {
v.contains(&address) || v.contains(&port.to_string())
})
|| attributes
.get("ADDRESS_s")
.and_then(Value::as_str)
.map_or(false, |v| v.contains(&address))
{
return Ok(session);
}
}
return Err(PacketBad.context("Servers were provided but the specified one couldn't be find amonst them."));
}
Err(PacketBad.context("Expected session field to be an array."))
}
pub fn query(&mut self, address: &SocketAddr) -> GDResult<Response> {
let value = self.query_raw(address)?;
let build_version = extract_optional_field!(value, ["attributes", "BUILDID_s"], Value::as_str);
let minor_version = extract_optional_field!(value, ["attributes", "MINORBUILDID_s"], Value::as_str);
let game_version = match (build_version, minor_version) {
(Some(b), Some(m)) => Some(format!("{b}.{m}")),
_ => None,
};
Ok(Response {
name: extract_field!(value, ["attributes", "CUSTOMSERVERNAME_s"], Value::as_str).to_string(),
map: extract_field!(value, ["attributes", "MAPNAME_s"], Value::as_str).to_string(),
has_password: extract_field!(value, ["attributes", "SERVERPASSWORD_b"], Value::as_bool),
players_online: extract_field!(value, ["totalPlayers"], Value::as_u64) as u32,
players_maxmimum: extract_field!(value, ["settings", "maxPublicPlayers"], Value::as_u64) as u32,
players: vec![],
game_version,
raw: value,
})
}
}
pub fn query(credentials: Credentials, address: &SocketAddr) -> GDResult<Response> {
query_with_timeout(credentials, address, None)
}
pub fn query_with_timeout(
credentials: Credentials,
address: &SocketAddr,
timeout_settings: Option<TimeoutSettings>,
) -> GDResult<Response> {
let mut client = EpicProtocol::new(credentials, timeout_settings.unwrap_or_default())?;
client.query(address)
}

View file

@ -0,0 +1,52 @@
use crate::protocols::types::{CommonPlayer, CommonResponse, GenericPlayer};
use crate::protocols::GenericResponse;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct Response {
pub name: String,
pub map: String,
pub has_password: bool,
pub players_online: u32,
pub players_maxmimum: u32,
pub players: Vec<Player>,
pub game_version: Option<String>,
pub raw: Value,
}
impl CommonResponse for Response {
fn as_original(&self) -> GenericResponse { GenericResponse::Epic(self) }
fn name(&self) -> Option<&str> { Some(&self.name) }
fn map(&self) -> Option<&str> { Some(&self.map) }
fn players_maximum(&self) -> u32 { self.players_maxmimum }
fn players_online(&self) -> u32 { self.players_online }
fn has_password(&self) -> Option<bool> { Some(self.has_password) }
fn players(&self) -> Option<Vec<&dyn CommonPlayer>> {
Some(
self.players
.iter()
.map(|p| p as &dyn CommonPlayer)
.collect(),
)
}
fn game_version(&self) -> Option<&str> { self.game_version.as_deref() }
}
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct Player {
pub name: String,
}
impl CommonPlayer for Player {
fn as_original(&self) -> GenericPlayer { GenericPlayer::Epic(self) }
fn name(&self) -> &str { &self.name }
}

View file

@ -4,6 +4,9 @@
//! implementation will be in that specific needed place, a protocol can be
//! independently queried.
#[cfg(feature = "tls")]
/// Reference: [node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/epic.js)
pub mod epic;
/// Reference: [node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/gamespy1.js)
pub mod gamespy;
/// Reference: [node-GameDig](https://github.com/gamedig/node-gamedig/blob/master/protocols/quake1.js)

View file

@ -1,5 +1,7 @@
#[cfg(feature = "games")]
use crate::games::minecraft;
#[cfg(feature = "tls")]
use crate::protocols::epic;
use crate::protocols::{gamespy, quake, unreal2, valve};
use crate::GDErrorKind::InvalidInput;
use crate::GDResult;
@ -31,6 +33,8 @@ pub enum Protocol {
Quake(quake::QuakeVersion),
Valve(valve::Engine),
Unreal2,
#[cfg(feature = "tls")]
Epic(epic::Credentials),
#[cfg(feature = "games")]
PROPRIETARY(ProprietaryProtocol),
}
@ -43,6 +47,8 @@ pub enum GenericResponse<'a> {
Quake(quake::VersionedResponse<'a>),
Valve(&'a valve::Response),
Unreal2(&'a unreal2::Response),
#[cfg(feature = "tls")]
Epic(&'a epic::Response),
#[cfg(feature = "games")]
Mindustry(&'a crate::games::mindustry::types::ServerData),
#[cfg(feature = "games")]
@ -68,6 +74,8 @@ pub enum GenericPlayer<'a> {
QuakeTwo(&'a quake::two::Player),
Gamespy(gamespy::VersionedPlayer<'a>),
Unreal2(&'a unreal2::Player),
#[cfg(feature = "tls")]
Epic(&'a epic::Player),
#[cfg(feature = "games")]
Minecraft(&'a minecraft::Player),
#[cfg(feature = "games")]