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