nil-server 0.5.0

Multiplayer strategy game
Documentation
// Copyright (C) Call of Nil contributors
// SPDX-License-Identifier: AGPL-3.0-only

use crate::error::{CoreError, DatabaseError, Error};
use axum::response::{IntoResponse, Response};
use either::Either;
use nil_server_database::error::DieselError;
use std::ops::{ControlFlow, Try};

pub type MaybeResponse<L> = Either<L, Response>;

#[doc(hidden)]
#[macro_export]
macro_rules! res {
  ($status:ident) => {{
    use axum::body::Body;
    use axum::http::StatusCode;
    use axum::response::Response;

    let status = StatusCode::$status;
    let body = if (status.is_client_error() || status.is_server_error())
      && let Some(reason) = status.canonical_reason()
    {
      Body::new(reason.to_string())
    } else {
      Body::empty()
    };

    Response::builder()
      .status(status)
      .body(body)
      .unwrap()
  }};
  ($status:ident, $data:expr) => {{
    use axum::http::StatusCode;
    use axum::response::IntoResponse;

    (StatusCode::$status, $data).into_response()
  }};
}

impl From<Error> for Response {
  fn from(err: Error) -> Self {
    from_err(err)
  }
}

impl IntoResponse for Error {
  fn into_response(self) -> Response {
    from_err(self)
  }
}

pub(crate) fn from_err(err: impl Into<Error>) -> Response {
  let err: Error = err.into();
  tracing::error!(message = %err, error = ?err);
  from_server_err(err)
}

#[expect(clippy::match_same_arms, clippy::needless_pass_by_value)]
fn from_core_err(err: CoreError) -> Response {
  use CoreError::*;

  let text = err.to_string();
  match err {
    ArmyNotFound(..) => res!(NOT_FOUND, text),
    ArmyNotIdle(..) => res!(BAD_REQUEST, text),
    BotAlreadySpawned(..) => res!(CONFLICT, text),
    BotNotFound(..) => res!(NOT_FOUND, text),
    BuildingStatsNotFound(..) => res!(NOT_FOUND, text),
    BuildingStatsNotFoundForLevel(..) => res!(NOT_FOUND, text),
    CannotDecreaseBuildingLevel(..) => res!(BAD_REQUEST, text),
    CannotIncreaseBuildingLevel(..) => res!(BAD_REQUEST, text),
    CheatingNotAllowed => res!(BAD_REQUEST, text),
    CityNotFound(..) => res!(NOT_FOUND, text),
    FailedToDeserializeEvent => res!(INTERNAL_SERVER_ERROR, text),
    FailedToReadSavedata => res!(INTERNAL_SERVER_ERROR, text),
    FailedToSerializeEvent => res!(INTERNAL_SERVER_ERROR, text),
    FailedToWriteSavedata => res!(INTERNAL_SERVER_ERROR, text),
    Forbidden => res!(FORBIDDEN, text),
    IndexOutOfBounds(..) => res!(BAD_REQUEST, text),
    InsufficientResources => res!(BAD_REQUEST, text),
    InsufficientUnits => res!(BAD_REQUEST, text),
    ManeuverIsDone(..) => res!(BAD_REQUEST, text),
    ManeuverIsPending(..) => res!(BAD_REQUEST, text),
    ManeuverIsReturning(..) => res!(BAD_REQUEST, text),
    ManeuverNotFound(..) => res!(NOT_FOUND, text),
    MineStatsNotFound(..) => res!(NOT_FOUND, text),
    MineStatsNotFoundForLevel(..) => res!(NOT_FOUND, text),
    NoPlayer => res!(BAD_REQUEST, text),
    NotWaitingPlayer(..) => res!(BAD_REQUEST, text),
    OriginIsDestination(..) => res!(BAD_REQUEST, text),
    PlayerAlreadySpawned(..) => res!(CONFLICT, text),
    PlayerNotFound(..) => res!(NOT_FOUND, text),
    PrecursorNotFound(..) => res!(NOT_FOUND, text),
    ReportNotFound(..) => res!(NOT_FOUND, text),
    RoundAlreadyStarted => res!(CONFLICT, text),
    RoundHasPendingPlayers => res!(BAD_REQUEST, text),
    RoundNotStarted => res!(BAD_REQUEST, text),
    StorageStatsNotFound(..) => res!(NOT_FOUND, text),
    StorageStatsNotFoundForLevel(..) => res!(NOT_FOUND, text),
    UnexpectedUnit(..) => res!(BAD_REQUEST, text),
    WallStatsNotFoundForLevel(..) => res!(NOT_FOUND, text),
    WorldIsFull => res!(FORBIDDEN, text),
  }
}

#[expect(clippy::match_same_arms)]
fn from_database_err(err: DatabaseError) -> Response {
  use DatabaseError::*;

  match err {
    Core(err) => from_core_err(err),
    Diesel(err) => from_diesel_err(&err),
    DieselConnection(..) => res!(INTERNAL_SERVER_ERROR),
    GameNotFound(..) => res!(NOT_FOUND, err.to_string()),
    InvalidPassword => res!(BAD_REQUEST, err.to_string()),
    InvalidUsername(..) => res!(BAD_REQUEST, err.to_string()),
    Io(..) => res!(INTERNAL_SERVER_ERROR),
    Jiff(..) => res!(INTERNAL_SERVER_ERROR),
    MigrationFailed(..) => res!(INTERNAL_SERVER_ERROR),
    UserAlreadyExists(..) => res!(CONFLICT, err.to_string()),
    UserNotFound(..) => res!(NOT_FOUND, err.to_string()),
    Unknown(..) => res!(INTERNAL_SERVER_ERROR),
  }
}

fn from_diesel_err(err: &DieselError) -> Response {
  if let DieselError::NotFound = &err {
    res!(NOT_FOUND)
  } else {
    res!(INTERNAL_SERVER_ERROR)
  }
}

#[expect(clippy::match_same_arms)]
fn from_server_err(err: Error) -> Response {
  use Error::*;

  match err {
    Core(err) => from_core_err(err),
    Database(err) => from_database_err(err),
    IncorrectUserCredentials => res!(UNAUTHORIZED, err.to_string()),
    IncorrectWorldCredentials(..) => res!(UNAUTHORIZED, err.to_string()),
    Io(..) => res!(INTERNAL_SERVER_ERROR),
    MissingPassword => res!(BAD_REQUEST, err.to_string()),
    Unknown(..) => res!(INTERNAL_SERVER_ERROR),
    WorldLimitReached => res!(FORBIDDEN, err.to_string()),
    WorldNotFound(..) => res!(NOT_FOUND, err.to_string()),
  }
}

pub trait EitherExt<L, R> {
  fn try_map_left<T, E, F>(self, f: F) -> Either<Response, R>
  where
    Self: Sized,
    L: Try<Output = T, Residual = E>,
    E: Into<Error>,
    F: FnOnce(T) -> Response;
}

impl<L, R> EitherExt<L, R> for Either<L, R> {
  fn try_map_left<T, E, F>(self, f: F) -> Either<Response, R>
  where
    Self: Sized,
    L: Try<Output = T, Residual = E>,
    E: Into<Error>,
    F: FnOnce(T) -> Response,
  {
    match self {
      Self::Left(left) => {
        match left.branch() {
          ControlFlow::Continue(value) => Either::Left(f(value)),
          ControlFlow::Break(err) => Either::Left(from_err(err)),
        }
      }
      Self::Right(right) => Either::Right(right),
    }
  }
}

#[doc(hidden)]
#[macro_export]
macro_rules! bail_if_player_is_not_pending {
  ($world:expr, $player:expr) => {
    if !$world.round().is_waiting_player($player) {
      use nil_core::error::Error;
      let err = Error::NotWaitingPlayer($player.clone());
      return $crate::response::from_err(err);
    }
  };
}

#[doc(hidden)]
#[macro_export]
macro_rules! bail_if_not_player {
  ($current_player:expr, $player:expr) => {
    if $current_player != $player {
      return $crate::res!(FORBIDDEN);
    }
  };
}

#[doc(hidden)]
#[macro_export]
macro_rules! bail_if_city_is_not_owned_by {
  ($world:expr, $player:expr, $coord:expr) => {
    if !$world
      .city($coord)?
      .is_owned_by_player_and(|id| $player == id)
    {
      return $crate::res!(FORBIDDEN);
    }
  };
}