mirror of
https://github.com/tribufu/rust-gamedig
synced 2026-06-01 09:42:41 +00:00
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:
parent
1620ba36b8
commit
45ffa53de3
15 changed files with 434 additions and 81 deletions
58
crates/lib/src/protocols/epic/mod.rs
Normal file
58
crates/lib/src/protocols/epic/mod.rs
Normal 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;
|
||||
181
crates/lib/src/protocols/epic/protocol.rs
Normal file
181
crates/lib/src/protocols/epic/protocol.rs
Normal 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)
|
||||
}
|
||||
52
crates/lib/src/protocols/epic/types.rs
Normal file
52
crates/lib/src/protocols/epic/types.rs
Normal 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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue