pikadick/database/
model.rs

1use bitflags::bitflags;
2use rusqlite::{
3    types::{
4        FromSql,
5        FromSqlError,
6        FromSqlResult,
7        ToSqlOutput,
8        ValueRef,
9    },
10    ToSql,
11};
12use serenity::{
13    model::prelude::*,
14    utils::parse_user_mention,
15};
16use std::{
17    borrow::Cow,
18    num::NonZeroU64,
19    str::FromStr,
20};
21
22/// A wrapper for a serenity user id
23struct DatabaseUserId(UserId);
24
25impl FromSql for DatabaseUserId {
26    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
27        // This is not heavy
28        #[allow(clippy::or_fun_call)]
29        let value = value
30            .as_i64()
31            .map(i64::to_ne_bytes)
32            .map(u64::from_ne_bytes)
33            .map(NonZeroU64::new)?
34            .ok_or(FromSqlError::OutOfRange(0))?;
35
36        Ok(Self(UserId::from(value)))
37    }
38}
39
40/// A Tic-Tac-Toe game
41#[derive(Debug, Copy, Clone)]
42pub struct TicTacToeGame {
43    /// The game board
44    pub board: tic_tac_toe::Board,
45    /// The x player
46    pub x_player: TicTacToePlayer,
47    /// The o player
48    pub o_player: TicTacToePlayer,
49}
50
51impl TicTacToeGame {
52    /// Make a new [`TicTacToeGame`].
53    pub(super) fn new(x_player: TicTacToePlayer, o_player: TicTacToePlayer) -> Self {
54        Self {
55            board: Default::default(),
56            x_player,
57            o_player,
58        }
59    }
60
61    /// Get whos turn it is
62    pub fn get_team_turn(&self) -> tic_tac_toe::Team {
63        self.board.get_turn()
64    }
65
66    /// Get the player for the given team.
67    pub fn get_player(&self, team: tic_tac_toe::Team) -> TicTacToePlayer {
68        match team {
69            tic_tac_toe::Team::X => self.x_player,
70            tic_tac_toe::Team::O => self.o_player,
71        }
72    }
73
74    /// Get the player whos turn it is
75    pub fn get_player_turn(&self) -> TicTacToePlayer {
76        self.get_player(self.get_team_turn())
77    }
78
79    /// Try to make a move.
80    ///
81    /// # Returns
82    /// Returns true if successful.
83    pub fn try_move(&mut self, index: u8, team: tic_tac_toe::Team) -> bool {
84        if index >= tic_tac_toe::NUM_TILES || self.board.get(index).is_some() {
85            false
86        } else {
87            self.board = self.board.set(index, Some(team));
88            true
89        }
90    }
91
92    /// Get the opponent of the given user in this [`TicTacToeGame`].
93    pub fn get_opponent(&self, player: TicTacToePlayer) -> Option<TicTacToePlayer> {
94        match (player == self.x_player, player == self.o_player) {
95            (false, false) => None,
96            (false, true) => Some(self.x_player),
97            (true, false) => Some(self.o_player),
98            (true, true) => Some(player), // Player is playing themselves
99        }
100    }
101
102    /// Iterate over all [`TicTacToePlayer`]s.
103    ///
104    /// Order is X player, O player.
105    /// This will include computer players.
106    /// Convert players into [`UserId`]s and filter if you want human players.
107    pub fn iter_players(&self) -> impl Iterator<Item = TicTacToePlayer> + '_ {
108        let mut count = 0;
109        std::iter::from_fn(move || {
110            let ret = match count {
111                0 => self.x_player,
112                1 => self.o_player,
113                _c => return None,
114            };
115            count += 1;
116            Some(ret)
117        })
118    }
119}
120
121#[derive(Debug, Clone)]
122pub struct TicTacToePlayerParseError(std::num::ParseIntError);
123
124impl std::fmt::Display for TicTacToePlayerParseError {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        "invalid player".fmt(f)
127    }
128}
129
130impl std::error::Error for TicTacToePlayerParseError {
131    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
132        Some(&self.0)
133    }
134}
135
136/// A player of Tic-Tac-Toe
137#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
138pub enum TicTacToePlayer {
139    /// AI player
140    Computer,
141
142    /// Another user
143    User(UserId),
144}
145
146impl TicTacToePlayer {
147    /// Check if this player is a computer
148    pub fn is_computer(self) -> bool {
149        matches!(self, Self::Computer)
150    }
151
152    /// Check if this player is a user
153    pub fn is_user(self) -> bool {
154        matches!(self, Self::User(_))
155    }
156
157    /// Extract the user id if this is a user
158    pub fn get_user(self) -> Option<UserId> {
159        match self {
160            Self::Computer => None,
161            Self::User(user_id) => Some(user_id),
162        }
163    }
164}
165
166impl From<TicTacToePlayer> for Cow<'static, str> {
167    fn from(player: TicTacToePlayer) -> Self {
168        match player {
169            TicTacToePlayer::Computer => "computer".into(),
170            TicTacToePlayer::User(id) => id.to_string().into(),
171        }
172    }
173}
174
175impl FromStr for TicTacToePlayer {
176    type Err = TicTacToePlayerParseError;
177
178    fn from_str(input: &str) -> Result<Self, Self::Err> {
179        if input.eq_ignore_ascii_case("computer") {
180            Ok(Self::Computer)
181        } else if let Some(user_id) = parse_user_mention(input) {
182            Ok(Self::User(user_id))
183        } else {
184            let user_id: NonZeroU64 = input.parse().map_err(TicTacToePlayerParseError)?;
185            Ok(Self::User(UserId::from(user_id)))
186        }
187    }
188}
189
190impl From<UserId> for TicTacToePlayer {
191    fn from(user_id: UserId) -> Self {
192        Self::User(user_id)
193    }
194}
195
196impl ToSql for TicTacToePlayer {
197    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
198        match self {
199            Self::Computer => Ok(ToSqlOutput::Borrowed(ValueRef::Null)),
200            Self::User(id) => Ok(ToSqlOutput::Borrowed(ValueRef::Integer(i64::from(*id)))),
201        }
202    }
203}
204
205impl FromSql for TicTacToePlayer {
206    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
207        match value {
208            ValueRef::Integer(int) => {
209                let int_u64 = u64::from_ne_bytes(int.to_ne_bytes());
210                // This is not heavy
211                #[allow(clippy::or_fun_call)]
212                let user_id =
213                    UserId::from(NonZeroU64::new(int_u64).ok_or(FromSqlError::OutOfRange(0))?);
214                Ok(Self::User(user_id))
215            }
216            ValueRef::Null => Ok(Self::Computer),
217            _ => Err(FromSqlError::InvalidType),
218        }
219    }
220}
221
222/// A String wrapper for a [`GuildId`]
223///
224/// This is "[u64].to_string()" if a guild, or "empty" if not.
225#[derive(Debug, Copy, Clone)]
226pub struct MaybeGuildString {
227    pub guild_id: Option<GuildId>,
228}
229
230impl From<Option<GuildId>> for MaybeGuildString {
231    fn from(guild_id: Option<GuildId>) -> Self {
232        Self { guild_id }
233    }
234}
235
236impl ToSql for MaybeGuildString {
237    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
238        match self.guild_id {
239            Some(guild_id) => Ok(ToSqlOutput::from(guild_id.to_string())),
240            None => Ok(ToSqlOutput::Borrowed(ValueRef::Text(b"empty"))),
241        }
242    }
243}
244
245impl FromSql for MaybeGuildString {
246    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
247        let text = value.as_str()?;
248        match text.parse::<NonZeroU64>() {
249            Ok(guild_id) => Ok(MaybeGuildString {
250                guild_id: Some(GuildId::from(guild_id)),
251            }),
252            Err(e) => {
253                if text == "empty" {
254                    Ok(MaybeGuildString { guild_id: None })
255                } else {
256                    Err(FromSqlError::Other(Box::new(e)))
257                }
258            }
259        }
260    }
261}
262
263/// Tic-Tac-Toe scores
264#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
265pub struct TicTacToeScore {
266    /// Wins
267    pub wins: u64,
268    /// Losses
269    pub losses: u64,
270    /// Ties
271    pub ties: u64,
272    /// The number of times the player has conceded
273    pub concedes: u64,
274}
275
276/// Top Player Tic-Tac-Toe scores
277#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
278pub struct TicTacToeTopPlayerScore {
279    /// The score
280    pub score: i64,
281    /// The player
282    pub player: UserId,
283    /// Wins
284    pub wins: u64,
285    /// Losses
286    pub losses: u64,
287    /// Ties
288    pub ties: u64,
289    /// The number of times the player has conceded
290    pub concedes: u64,
291}
292
293impl TicTacToeTopPlayerScore {
294    /// Parse this from a rusqlite row.
295    ///
296    /// Data must be in the following order:
297    /// 1. score
298    /// 2. player
299    /// 3. wins
300    /// 4. losses
301    /// 5. ties
302    /// 6. concedes
303    pub(crate) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
304        let score = row.get(0)?;
305
306        let player = row.get::<_, DatabaseUserId>(1)?.0;
307
308        let wins = row.get(2)?;
309
310        let losses = row.get(3)?;
311
312        let ties = row.get(4)?;
313
314        let concedes = row.get(5)?;
315
316        Ok(TicTacToeTopPlayerScore {
317            score,
318            player,
319            wins,
320            losses,
321            ties,
322            concedes,
323        })
324    }
325}
326
327bitflags! {
328    /// Flags for TikTok embeds
329    #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
330    pub struct TikTokEmbedFlags: u32 {
331        /// Whether embeds are enabled
332        const ENABLED = 1 << 0;
333        /// Whether the bot should delete old links
334        const DELETE_LINK = 1 << 1;
335    }
336}
337
338impl ToSql for TikTokEmbedFlags {
339    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
340        Ok(self.bits().into())
341    }
342}
343
344impl FromSql for TikTokEmbedFlags {
345    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
346        let value = value.as_i64()?;
347        let value = u32::try_from(value).map_err(|_e| FromSqlError::OutOfRange(value))?;
348
349        Self::from_bits(value).ok_or_else(|| FromSqlError::OutOfRange(value.into()))
350    }
351}
352
353impl Default for TikTokEmbedFlags {
354    fn default() -> Self {
355        Self::empty()
356    }
357}