nil-core 0.5.4

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

#[cfg(test)]
mod tests;

use crate::error::{Error, Result};
use crate::player::PlayerId;
use derive_more::Deref;
use jiff::Zoned;
use nil_util::iter::IterExt;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
use std::num::NonZeroU32;
use strum::EnumIs;

#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive_const(Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct Round {
  id: RoundId,
  state: RoundState,
  started_at: Option<Zoned>,
}

impl Round {
  pub(crate) fn start<I>(&mut self, players: I) -> Result<()>
  where
    I: IntoIterator<Item = PlayerId>,
  {
    if let RoundState::Idle = &self.state {
      self.started_at = Some(Zoned::now());
      self.wait_players(players);
      Ok(())
    } else {
      Err(Error::RoundAlreadyStarted)
    }
  }

  /// Tries to advance to the next round.
  pub(crate) fn next<I>(&mut self, players: I) -> Result<()>
  where
    I: IntoIterator<Item = PlayerId>,
  {
    match &self.state {
      RoundState::Idle => Err(Error::RoundNotStarted),
      RoundState::Waiting { pending, .. } if !pending.is_empty() => {
        Err(Error::RoundHasPendingPlayers)
      }
      RoundState::Waiting { .. } | RoundState::Done => {
        self.id = self.id.next();
        self.started_at = Some(Zoned::now());
        self.wait_players(players);
        Ok(())
      }
    }
  }

  /// Sets the round state to [`RoundState::Waiting`],
  /// where players are expected to take their turns.
  ///
  /// If `players` is empty, the round will be set to [`RoundState::Done`] instead.
  fn wait_players<I>(&mut self, players: I)
  where
    I: IntoIterator<Item = PlayerId>,
  {
    let pending = players.into_iter().collect_set();
    if pending.is_empty() {
      self.dangerously_set_done();
    } else {
      let ready = HashSet::with_capacity(pending.len());
      self.state = RoundState::Waiting { pending, ready };
    }
  }

  pub(crate) fn set_ready(&mut self, player: &PlayerId, is_ready: bool) {
    if let RoundState::Waiting { pending, ready } = &mut self.state {
      #[expect(clippy::collapsible_else_if)]
      if is_ready {
        if pending.remove(player) {
          ready.insert(player.clone());
        }
      } else {
        if ready.remove(player) {
          pending.insert(player.clone());
        }
      }

      if pending.is_empty() {
        self.dangerously_set_done();
      }
    }
  }

  pub(crate) fn dangerously_set_done(&mut self) {
    debug_assert!(!self.state.is_idle());
    self.state = RoundState::Done;
  }

  #[inline]
  pub fn id(&self) -> RoundId {
    self.id
  }

  #[inline]
  pub fn state(&self) -> &RoundState {
    &self.state
  }

  #[inline]
  pub fn is_idle(&self) -> bool {
    self.state.is_idle()
  }

  #[inline]
  pub fn is_done(&self) -> bool {
    self.state.is_done()
  }

  #[inline]
  pub fn is_waiting(&self) -> bool {
    self.state.is_waiting()
  }

  #[inline]
  pub fn is_waiting_player(&self, player: &PlayerId) -> bool {
    if let RoundState::Waiting { pending, ready } = &self.state {
      pending.contains(player) || ready.contains(player)
    } else {
      false
    }
  }

  #[inline]
  pub fn is_player_pending(&self, player: &PlayerId) -> bool {
    if let RoundState::Waiting { pending, .. } = &self.state {
      pending.contains(player)
    } else {
      false
    }
  }

  #[inline]
  pub fn is_player_ready(&self, player: &PlayerId) -> bool {
    if let RoundState::Waiting { ready, .. } = &self.state {
      ready.contains(player)
    } else {
      false
    }
  }

  #[inline]
  pub fn started_at(&self) -> Result<&Zoned> {
    self
      .started_at
      .as_ref()
      .ok_or(Error::RoundNotStarted)
  }
}

#[derive(Copy, Debug, Deref, Deserialize, Serialize)]
#[derive_const(Clone, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RoundId(NonZeroU32);

impl RoundId {
  #[must_use]
  const fn next(self) -> RoundId {
    Self(self.0.saturating_add(1))
  }
}

impl const Default for RoundId {
  fn default() -> Self {
    Self(NonZeroU32::MIN)
  }
}

impl const PartialEq<u32> for RoundId {
  fn eq(&self, other: &u32) -> bool {
    self.0.get().eq(other)
  }
}

#[derive(Clone, Deserialize, Serialize, EnumIs)]
#[derive_const(Default)]
#[serde(tag = "kind", rename_all = "kebab-case")]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub enum RoundState {
  /// The game hasn't started yet.
  #[default]
  Idle,

  /// There are players who haven't finished their turn yet.
  Waiting {
    pending: HashSet<PlayerId>,
    ready: HashSet<PlayerId>,
  },

  /// The round is finished.
  Done,
}

impl fmt::Debug for RoundState {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    match self {
      Self::Idle => write!(f, "Idle"),
      Self::Waiting { pending, ready } => {
        f.debug_struct("Waiting")
          .field("pending", &pending.len())
          .field("ready", &ready.len())
          .finish()
      }
      Self::Done => write!(f, "Done"),
    }
  }
}