use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::envelope::{Ack, ServerPush};
use crate::error::{ApiError, ValidationDetails};
use crate::ids::{TicketId, UserId};
use crate::kinds;
use crate::time::UnixMillis;
pub const MAX_QUEUE_KEY_BYTES: usize = 64;
pub const DEFAULT_MAX_ATTRIBUTES_BYTES: usize = 1024;
pub fn is_valid_queue_key(key: &str) -> bool {
let bytes = key.as_bytes();
bytes.len() <= MAX_QUEUE_KEY_BYTES
&& bytes.first().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
&& bytes.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.'))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct QueueInfo {
pub key: String,
pub players: u32,
pub waiting: u32,
}
impl QueueInfo {
pub fn new(key: impl Into<String>, players: u32, waiting: u32) -> Self {
Self { key: key.into(), players, waiting }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Queues {
pub queues: Vec<QueueInfo>,
}
impl Queues {
pub fn new(queues: Vec<QueueInfo>) -> Self {
Self { queues }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CreateTicket {
pub queue: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attributes: Option<Value>,
}
impl CreateTicket {
pub fn new(queue: impl Into<String>) -> Self {
Self { queue: queue.into(), attributes: None }
}
pub fn with_attributes(mut self, attributes: Value) -> Self {
self.attributes = Some(attributes);
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if !is_valid_queue_key(&self.queue) {
details.add("queue", "is not a valid queue key");
}
details.into_result()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TicketStatus {
Waiting,
Matched,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MatchTicket {
pub id: TicketId,
pub queue: String,
pub status: TicketStatus,
pub created_at: UnixMillis,
pub expires_at: UnixMillis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub found: Option<MatchFound>,
}
impl MatchTicket {
pub fn new(id: TicketId, queue: impl Into<String>, created_at: UnixMillis, expires_at: UnixMillis) -> Self {
Self { id, queue: queue.into(), status: TicketStatus::Waiting, created_at, expires_at, found: None }
}
pub fn with_match(mut self, found: MatchFound) -> Self {
self.status = TicketStatus::Matched;
self.found = Some(found);
self
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MatchFound {
pub ticket: TicketId,
pub queue: String,
pub players: Vec<UserId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl MatchFound {
pub fn new(ticket: TicketId, queue: impl Into<String>, players: Vec<UserId>) -> Self {
Self { ticket, queue: queue.into(), players, data: None }
}
pub fn with_data(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
}
impl ServerPush for MatchFound {
const KIND: &'static str = kinds::MATCH_FOUND;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TicketExpired {
pub ticket: TicketId,
pub queue: String,
}
impl TicketExpired {
pub fn new(ticket: TicketId, queue: impl Into<String>) -> Self {
Self { ticket, queue: queue.into() }
}
}
impl ServerPush for TicketExpired {
const KIND: &'static str = kinds::MATCH_EXPIRED;
}
mod calls {
use super::*;
use crate::http_call::{payload_call, HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::routes::{self, HttpMethod, Route};
payload_call!(CreateTicket, Post, routes::matchmaking::TICKET, true, Json, MatchTicket);
macro_rules! plain_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {}
impl $name {
pub fn new() -> Self {
Self {}
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn from_parts(_params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new())
}
}
};
}
plain_call!(
ListQueues,
Get,
routes::matchmaking::QUEUES,
Queues
);
plain_call!(
GetTicket,
Get,
routes::matchmaking::TICKET,
MatchTicket
);
plain_call!(
CancelTicket,
Delete,
routes::matchmaking::TICKET,
Ack
);
}
pub use calls::{CancelTicket, GetTicket, ListQueues};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_json_and_rules() {
for good in ["duel", "ranked.eu-2v2", "1v1", &"q".repeat(MAX_QUEUE_KEY_BYTES)] {
assert!(is_valid_queue_key(good), "{good}");
}
for bad in ["", "Duel", "-x", "a b", "a/b", &"q".repeat(MAX_QUEUE_KEY_BYTES + 1)] {
assert!(!is_valid_queue_key(bad), "{bad}");
}
assert!(CreateTicket::new("duel").validate().is_ok() && CreateTicket::new("Duel").validate().is_err());
let create = CreateTicket::new("duel").with_attributes(serde_json::json!({"rating": 1520}));
assert_eq!(serde_json::to_string(&create).ok().as_deref(), Some(r#"{"queue":"duel","attributes":{"rating":1520}}"#));
let found = MatchFound::new(TicketId(9), "duel", vec![UserId(42), UserId(7)]).with_data(serde_json::json!({"lobby": 12}));
assert_eq!(serde_json::to_string(&found).ok().as_deref(), Some(r#"{"ticket":9,"queue":"duel","players":[42,7],"data":{"lobby":12}}"#));
let ticket = MatchTicket::new(TicketId(9), "duel", UnixMillis(1), UnixMillis(2));
assert_eq!(serde_json::to_string(&ticket).ok().as_deref(), Some(r#"{"id":9,"queue":"duel","status":"waiting","created_at":1,"expires_at":2}"#));
assert_eq!(ticket.with_match(found).status, TicketStatus::Matched);
assert_eq!(serde_json::from_str::<TicketStatus>(r#""cancelled""#).ok(), Some(TicketStatus::Unknown));
assert_eq!(serde_json::to_string(&TicketExpired::new(TicketId(9), "duel")).ok().as_deref(), Some(r#"{"ticket":9,"queue":"duel"}"#));
}
}