Skip to main content

nil_core/round/
mod.rs

1// Copyright (C) Call of Nil contributors
2// SPDX-License-Identifier: AGPL-3.0-only
3
4#[cfg(test)]
5mod tests;
6
7use crate::error::{Error, Result};
8use crate::player::PlayerId;
9use derive_more::Deref;
10use jiff::Zoned;
11use nil_util::iter::IterExt;
12use serde::{Deserialize, Serialize};
13use std::collections::HashSet;
14use std::fmt;
15use std::num::NonZeroU32;
16use strum::EnumIs;
17
18#[derive(Clone, Debug, Deserialize, Serialize)]
19#[derive_const(Default)]
20#[serde(rename_all = "camelCase")]
21#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
22pub struct Round {
23  id: RoundId,
24  state: RoundState,
25  started_at: Option<Zoned>,
26}
27
28impl Round {
29  pub(crate) fn start<I>(&mut self, players: I) -> Result<()>
30  where
31    I: IntoIterator<Item = PlayerId>,
32  {
33    if let RoundState::Idle = &self.state {
34      self.started_at = Some(Zoned::now());
35      self.wait_players(players);
36      Ok(())
37    } else {
38      Err(Error::RoundAlreadyStarted)
39    }
40  }
41
42  /// Tries to advance to the next round.
43  pub(crate) fn next<I>(&mut self, players: I) -> Result<()>
44  where
45    I: IntoIterator<Item = PlayerId>,
46  {
47    match &self.state {
48      RoundState::Idle => Err(Error::RoundNotStarted),
49      RoundState::Waiting { pending, .. } if !pending.is_empty() => {
50        Err(Error::RoundHasPendingPlayers)
51      }
52      RoundState::Waiting { .. } | RoundState::Done => {
53        self.id = self.id.next();
54        self.started_at = Some(Zoned::now());
55        self.wait_players(players);
56        Ok(())
57      }
58    }
59  }
60
61  /// Sets the round state to [`RoundState::Waiting`],
62  /// where players are expected to take their turns.
63  ///
64  /// If `players` is empty, the round will be set to [`RoundState::Done`] instead.
65  fn wait_players<I>(&mut self, players: I)
66  where
67    I: IntoIterator<Item = PlayerId>,
68  {
69    let pending = players.into_iter().collect_set();
70    if pending.is_empty() {
71      self.dangerously_set_done();
72    } else {
73      let ready = HashSet::with_capacity(pending.len());
74      self.state = RoundState::Waiting { pending, ready };
75    }
76  }
77
78  pub(crate) fn set_ready(&mut self, player: &PlayerId, is_ready: bool) {
79    if let RoundState::Waiting { pending, ready } = &mut self.state {
80      #[expect(clippy::collapsible_else_if)]
81      if is_ready {
82        if pending.remove(player) {
83          ready.insert(player.clone());
84        }
85      } else {
86        if ready.remove(player) {
87          pending.insert(player.clone());
88        }
89      }
90
91      if pending.is_empty() {
92        self.dangerously_set_done();
93      }
94    }
95  }
96
97  pub(crate) fn dangerously_set_done(&mut self) {
98    debug_assert!(!self.state.is_idle());
99    self.state = RoundState::Done;
100  }
101
102  #[inline]
103  pub fn id(&self) -> RoundId {
104    self.id
105  }
106
107  #[inline]
108  pub fn state(&self) -> &RoundState {
109    &self.state
110  }
111
112  #[inline]
113  pub fn is_idle(&self) -> bool {
114    self.state.is_idle()
115  }
116
117  #[inline]
118  pub fn is_done(&self) -> bool {
119    self.state.is_done()
120  }
121
122  #[inline]
123  pub fn is_waiting(&self) -> bool {
124    self.state.is_waiting()
125  }
126
127  #[inline]
128  pub fn is_waiting_player(&self, player: &PlayerId) -> bool {
129    if let RoundState::Waiting { pending, ready } = &self.state {
130      pending.contains(player) || ready.contains(player)
131    } else {
132      false
133    }
134  }
135
136  #[inline]
137  pub fn is_player_pending(&self, player: &PlayerId) -> bool {
138    if let RoundState::Waiting { pending, .. } = &self.state {
139      pending.contains(player)
140    } else {
141      false
142    }
143  }
144
145  #[inline]
146  pub fn is_player_ready(&self, player: &PlayerId) -> bool {
147    if let RoundState::Waiting { ready, .. } = &self.state {
148      ready.contains(player)
149    } else {
150      false
151    }
152  }
153
154  #[inline]
155  pub fn started_at(&self) -> Result<&Zoned> {
156    self
157      .started_at
158      .as_ref()
159      .ok_or(Error::RoundNotStarted)
160  }
161}
162
163#[derive(Copy, Debug, Deref, Deserialize, Serialize)]
164#[derive_const(Clone, PartialEq, Eq, PartialOrd, Ord)]
165#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
166pub struct RoundId(NonZeroU32);
167
168impl RoundId {
169  #[must_use]
170  const fn next(self) -> RoundId {
171    Self(self.0.saturating_add(1))
172  }
173}
174
175impl const Default for RoundId {
176  fn default() -> Self {
177    Self(NonZeroU32::MIN)
178  }
179}
180
181impl const PartialEq<u32> for RoundId {
182  fn eq(&self, other: &u32) -> bool {
183    self.0.get().eq(other)
184  }
185}
186
187#[derive(Clone, Deserialize, Serialize, EnumIs)]
188#[derive_const(Default)]
189#[serde(tag = "kind", rename_all = "kebab-case")]
190#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
191pub enum RoundState {
192  /// The game hasn't started yet.
193  #[default]
194  Idle,
195
196  /// There are players who haven't finished their turn yet.
197  Waiting {
198    pending: HashSet<PlayerId>,
199    ready: HashSet<PlayerId>,
200  },
201
202  /// The round is finished.
203  Done,
204}
205
206impl fmt::Debug for RoundState {
207  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208    match self {
209      Self::Idle => write!(f, "Idle"),
210      Self::Waiting { pending, ready } => {
211        f.debug_struct("Waiting")
212          .field("pending", &pending.len())
213          .field("ready", &ready.len())
214          .finish()
215      }
216      Self::Done => write!(f, "Done"),
217    }
218  }
219}