pikadick/database/
tic_tac_toe.rs

1use crate::database::{
2    model::{
3        MaybeGuildString,
4        TicTacToeGame,
5        TicTacToePlayer,
6        TicTacToeScore,
7        TicTacToeTopPlayerScore,
8    },
9    Database,
10};
11use anyhow::Context;
12use rusqlite::{
13    named_params,
14    params,
15    OptionalExtension,
16    TransactionBehavior,
17};
18use serenity::model::prelude::*;
19use tic_tac_toe::Board;
20
21// Tic-Tac-Toe SQL
22const DELETE_TIC_TAC_TOE_GAME_SQL: &str = include_str!("../../sql/delete_tic_tac_toe_game.sql");
23const UPDATE_TIC_TAC_TOE_GAME_SQL: &str = include_str!("../../sql/update_tic_tac_toe_game.sql");
24const CREATE_TIC_TAC_TOE_GAME_SQL: &str = include_str!("../../sql/create_tic_tac_toe_game.sql");
25const GET_TIC_TAC_TOE_GAME_SQL: &str = include_str!("../../sql/get_tic_tac_toe_game.sql");
26const CHECK_IN_TIC_TAC_TOE_GAME_SQL: &str = include_str!("../../sql/check_in_tic_tac_toe_game.sql");
27const CREATE_DEFAULT_SCORE_TIC_TAC_TOE_SQL: &str =
28    include_str!("../../sql/create_default_score_tic_tac_toe.sql");
29const INCREMENT_TIES_SCORE_TIC_TAC_TOE_SQL: &str =
30    include_str!("../../sql/increment_ties_score_tic_tac_toe.sql");
31const INCREMENT_WINS_SCORE_TIC_TAC_TOE_SQL: &str =
32    include_str!("../../sql/increment_wins_score_tic_tac_toe.sql");
33const INCREMENT_LOSSES_SCORE_TIC_TAC_TOE_SQL: &str =
34    include_str!("../../sql/increment_losses_score_tic_tac_toe.sql");
35const INCREMENT_CONCEDES_SCORE_TIC_TAC_TOE_SQL: &str =
36    include_str!("../../sql/increment_concedes_score_tic_tac_toe.sql");
37const GET_TIC_TAC_TOE_SCORE_SQL: &str = include_str!("../../sql/get_tic_tac_toe_score.sql");
38const GET_TOP_TIC_TAC_TOE_SCORES_SQL: &str =
39    include_str!("../../sql/get_top_tic_tac_toe_scores.sql");
40
41/// Error that may occur while creating a tic-tac-toe game
42#[derive(Debug, thiserror::Error)]
43pub enum TicTacToeCreateGameError {
44    /// The author is in a game
45    #[error("the author is in a game")]
46    AuthorInGame,
47
48    /// The opponent is in a game
49    #[error("the opponent is in a game")]
50    OpponentInGame,
51
52    /// Error accessing the database
53    #[error("database error")]
54    Database(#[source] anyhow::Error),
55}
56
57/// Error that may occur while performing a tic-tac-toe move
58#[derive(Debug, thiserror::Error)]
59pub enum TicTacToeTryMoveError {
60    /// The user is not in a game
61    #[error("not in a game")]
62    NotInAGame,
63
64    /// It is not the user's turn
65    #[error("not the user's turn to move")]
66    InvalidTurn,
67
68    /// The move is invalid
69    #[error("the move is not valid")]
70    InvalidMove,
71
72    /// Error accessing the database
73    #[error("database error")]
74    Database(#[source] anyhow::Error),
75}
76
77/// The response for making a tic-tac-toe move
78#[derive(Debug, Copy, Clone)]
79pub enum TicTacToeTryMoveResponse {
80    /// There was a winner
81    Winner {
82        game: TicTacToeGame,
83        winner: TicTacToePlayer,
84        loser: TicTacToePlayer,
85    },
86    /// There was a tie
87    Tie { game: TicTacToeGame },
88    /// The next turn executed
89    NextTurn { game: TicTacToeGame },
90}
91
92fn get_tic_tac_toe_game(
93    txn: &rusqlite::Transaction<'_>,
94    guild_id: MaybeGuildString,
95    user_id: TicTacToePlayer,
96) -> rusqlite::Result<Option<(i64, TicTacToeGame)>> {
97    txn.prepare_cached(GET_TIC_TAC_TOE_GAME_SQL)?
98        .query_row(
99            named_params! {
100                ":guild_id": guild_id,
101                ":user_id": user_id
102            },
103            |row| {
104                Ok((
105                    row.get(0)?,
106                    TicTacToeGame {
107                        board: Board::decode_u16(row.get(1)?),
108                        x_player: row.get(2)?,
109                        o_player: row.get(3)?,
110                    },
111                ))
112            },
113        )
114        .optional()
115}
116
117fn update_tic_tac_toe_game(
118    txn: &rusqlite::Transaction<'_>,
119    id: i64,
120    board: Board,
121) -> rusqlite::Result<()> {
122    txn.prepare_cached(UPDATE_TIC_TAC_TOE_GAME_SQL)?
123        .execute(params![board.encode_u16(), id])?;
124    Ok(())
125}
126
127fn delete_tic_tac_toe_game(txn: &rusqlite::Transaction<'_>, id: i64) -> rusqlite::Result<()> {
128    txn.prepare_cached(DELETE_TIC_TAC_TOE_GAME_SQL)?
129        .execute([id])?;
130    Ok(())
131}
132
133/// Try to make a user's score data
134fn create_user_score_data(
135    txn: &rusqlite::Transaction<'_>,
136    guild_id: MaybeGuildString,
137    user_id: UserId,
138) -> rusqlite::Result<()> {
139    txn.prepare_cached(CREATE_DEFAULT_SCORE_TIC_TAC_TOE_SQL)?
140        .execute(params![guild_id, i64::from(user_id)])?;
141
142    Ok(())
143}
144
145/// Set a tic-tac-toe game as a draw as part of a larger transaction, consuming it.
146fn set_draw_tic_tac_toe_game(
147    txn: rusqlite::Transaction<'_>,
148    id: i64,
149    guild_id: MaybeGuildString,
150    game: TicTacToeGame,
151) -> anyhow::Result<()> {
152    delete_tic_tac_toe_game(&txn, id).context("failed to delete game")?;
153
154    if let (TicTacToePlayer::User(x_player), TicTacToePlayer::User(o_player)) =
155        (game.x_player, game.o_player)
156    {
157        create_user_score_data(&txn, guild_id, x_player)?;
158        create_user_score_data(&txn, guild_id, o_player)?;
159
160        txn.prepare_cached(INCREMENT_TIES_SCORE_TIC_TAC_TOE_SQL)?
161            .execute(params![guild_id, i64::from(x_player), i64::from(o_player)])?;
162    }
163
164    txn.commit().context("failed to commit")?;
165
166    Ok(())
167}
168
169/// Set a tic-tac-toe game as a win as part of a larger transaction, consuming it.
170fn set_win_tic_tac_toe_game(
171    txn: rusqlite::Transaction<'_>,
172    id: i64,
173    guild_id: MaybeGuildString,
174    winner: TicTacToePlayer,
175    loser: TicTacToePlayer,
176) -> anyhow::Result<()> {
177    delete_tic_tac_toe_game(&txn, id).context("failed to delete game")?;
178
179    if let (TicTacToePlayer::User(winner), TicTacToePlayer::User(loser)) = (winner, loser) {
180        create_user_score_data(&txn, guild_id, winner)?;
181        create_user_score_data(&txn, guild_id, loser)?;
182
183        txn.prepare_cached(INCREMENT_WINS_SCORE_TIC_TAC_TOE_SQL)?
184            .execute(params![guild_id, i64::from(winner)])?;
185        txn.prepare_cached(INCREMENT_LOSSES_SCORE_TIC_TAC_TOE_SQL)?
186            .execute(params![guild_id, i64::from(loser)])?;
187    }
188
189    txn.commit().context("failed to commit")?;
190
191    Ok(())
192}
193
194impl Database {
195    /// Create a new tic-tac-toe game
196    pub async fn create_tic_tac_toe_game(
197        &self,
198        guild_id: MaybeGuildString,
199        author: TicTacToePlayer,
200        author_team: tic_tac_toe::Team,
201        opponent: TicTacToePlayer,
202    ) -> Result<TicTacToeGame, TicTacToeCreateGameError> {
203        let (x_player, o_player) = if author_team == tic_tac_toe::Team::X {
204            (author, opponent)
205        } else {
206            (opponent, author)
207        };
208
209        self.access_db(move |db| {
210            let txn = db
211                .transaction_with_behavior(TransactionBehavior::Immediate)
212                .context("failed to create transaction")
213                .map_err(TicTacToeCreateGameError::Database)?;
214
215            let check_in_game_result: Option<(TicTacToePlayer, TicTacToePlayer)> = txn
216                .prepare_cached(CHECK_IN_TIC_TAC_TOE_GAME_SQL)
217                .context("failed to prepare query")
218                .map_err(TicTacToeCreateGameError::Database)?
219                .query_row(
220                    named_params! {
221                        ":guild_id": guild_id,
222                        ":author": author,
223                        ":opponent": opponent,
224                    },
225                    |row| Ok((row.get(0)?, row.get(1)?)),
226                )
227                .optional()
228                .context("failed to query if in game")
229                .map_err(TicTacToeCreateGameError::Database)?;
230
231            if let Some((maybe_x_player_in_game, maybe_o_player_in_game)) = check_in_game_result {
232                if maybe_x_player_in_game == author || maybe_o_player_in_game == author {
233                    return Err(TicTacToeCreateGameError::AuthorInGame);
234                }
235
236                if maybe_x_player_in_game == opponent || maybe_o_player_in_game == opponent {
237                    return Err(TicTacToeCreateGameError::OpponentInGame);
238                }
239            }
240
241            let mut game = TicTacToeGame::new(x_player, o_player);
242
243            // TODO: Iteratively perform AI steps?
244            if x_player.is_computer() {
245                let (_score, index) = tic_tac_toe::minimax(game.board, tic_tac_toe::NUM_TILES);
246                game.board = game.board.set(index, Some(tic_tac_toe::Team::X));
247            }
248
249            let board = game.board.encode_u16();
250            txn.prepare_cached(CREATE_TIC_TAC_TOE_GAME_SQL)
251                .context("failed to prepare query")
252                .map_err(TicTacToeCreateGameError::Database)?
253                .execute(params![board, x_player, o_player, guild_id])
254                .context("failed to create game in database")
255                .map_err(TicTacToeCreateGameError::Database)?;
256
257            txn.commit()
258                .context("failed to commit")
259                .map_err(TicTacToeCreateGameError::Database)?;
260
261            Ok(game)
262        })
263        .await
264        .context("database access failed to join")
265        .map_err(TicTacToeCreateGameError::Database)?
266    }
267
268    /// Try to make a tic-tac-toe move
269    pub async fn try_tic_tac_toe_move(
270        &self,
271        guild_id: MaybeGuildString,
272        player: TicTacToePlayer,
273        move_index: u8,
274    ) -> Result<TicTacToeTryMoveResponse, TicTacToeTryMoveError> {
275        self.access_db(move |db| {
276            let txn = db
277                .transaction_with_behavior(TransactionBehavior::Immediate)
278                .context("failed to create transaction")
279                .map_err(TicTacToeTryMoveError::Database)?;
280
281            let (id, mut game) = get_tic_tac_toe_game(&txn, guild_id, player)
282                .context("failed to get game")
283                .map_err(TicTacToeTryMoveError::Database)?
284                .ok_or(TicTacToeTryMoveError::NotInAGame)?;
285
286            let player_turn = game.get_player_turn();
287            if player != player_turn {
288                return Err(TicTacToeTryMoveError::InvalidTurn);
289            }
290
291            let team_turn = game.get_team_turn();
292            let move_successful = game.try_move(move_index, team_turn);
293            if !move_successful {
294                return Err(TicTacToeTryMoveError::InvalidMove);
295            }
296
297            if let Some(winner_team) = game.board.get_winner() {
298                let winner = game.get_player(winner_team);
299                let loser = game.get_player(winner_team.inverse());
300
301                set_win_tic_tac_toe_game(txn, id, guild_id, winner, loser)
302                    .map_err(TicTacToeTryMoveError::Database)?;
303
304                return Ok(TicTacToeTryMoveResponse::Winner {
305                    game,
306                    winner,
307                    loser,
308                });
309            }
310
311            if game.board.is_draw() {
312                set_draw_tic_tac_toe_game(txn, id, guild_id, game)
313                    .map_err(TicTacToeTryMoveError::Database)?;
314                return Ok(TicTacToeTryMoveResponse::Tie { game });
315            }
316
317            let opponent = game.get_player_turn();
318            if opponent == TicTacToePlayer::Computer {
319                let (_score, index) = tic_tac_toe::minimax(game.board, tic_tac_toe::NUM_TILES);
320                game.board = game.board.set(index, Some(team_turn.inverse()));
321
322                if let Some(winner_team) = game.board.get_winner() {
323                    let winner = game.get_player(winner_team);
324                    let loser = game.get_player(winner_team.inverse());
325
326                    set_win_tic_tac_toe_game(txn, id, guild_id, winner, loser)
327                        .map_err(TicTacToeTryMoveError::Database)?;
328
329                    return Ok(TicTacToeTryMoveResponse::Winner {
330                        game,
331                        winner,
332                        loser,
333                    });
334                }
335
336                if game.board.is_draw() {
337                    set_draw_tic_tac_toe_game(txn, id, guild_id, game)
338                        .map_err(TicTacToeTryMoveError::Database)?;
339                    return Ok(TicTacToeTryMoveResponse::Tie { game });
340                }
341            }
342
343            update_tic_tac_toe_game(&txn, id, game.board)
344                .context("failed to update game")
345                .map_err(TicTacToeTryMoveError::Database)?;
346
347            txn.commit()
348                .context("failed to commit")
349                .map_err(TicTacToeTryMoveError::Database)?;
350
351            Ok(TicTacToeTryMoveResponse::NextTurn { game })
352        })
353        .await
354        .context("database access failed to join")
355        .map_err(TicTacToeTryMoveError::Database)?
356    }
357
358    /// Try to get a tic-tac-toe game by guild and player
359    pub async fn get_tic_tac_toe_game(
360        &self,
361        guild_id: MaybeGuildString,
362        player: TicTacToePlayer,
363    ) -> anyhow::Result<Option<TicTacToeGame>> {
364        self.access_db(move |db| {
365            let txn = db.transaction()?;
366            let ret = get_tic_tac_toe_game(&txn, guild_id, player).context("failed to query")?;
367            txn.commit()
368                .context("failed to commit")
369                .map(|_| ret.map(|ret| ret.1))
370        })
371        .await?
372    }
373
374    /// Try to concede a Tic-Tac-Toe game.
375    ///
376    /// # Returns
377    /// Returns the game if it existed
378    pub async fn concede_tic_tac_toe_game(
379        &self,
380        guild_id: MaybeGuildString,
381        player: UserId,
382    ) -> anyhow::Result<Option<TicTacToeGame>> {
383        self.access_db(move |db| {
384            let txn = db.transaction()?;
385            let ret =
386                get_tic_tac_toe_game(&txn, guild_id, player.into()).context("failed to query")?;
387
388            if let Some((id, game)) = ret {
389                delete_tic_tac_toe_game(&txn, id).context("failed to delete game")?;
390
391                let conceding_player = TicTacToePlayer::from(player);
392                let opponent = game
393                    .get_opponent(conceding_player)
394                    .context("missing opponent")?;
395
396                if let (TicTacToePlayer::User(conceding_player), TicTacToePlayer::User(opponent)) =
397                    (conceding_player, opponent)
398                {
399                    txn.prepare_cached(INCREMENT_CONCEDES_SCORE_TIC_TAC_TOE_SQL)?
400                        .execute(params![guild_id, i64::from(conceding_player)])?;
401
402                    txn.prepare_cached(INCREMENT_WINS_SCORE_TIC_TAC_TOE_SQL)?
403                        .execute(params![guild_id, i64::from(opponent)])?;
404                }
405            }
406
407            txn.commit()
408                .context("failed to commit")
409                .map(|_| ret.map(|ret| ret.1))
410        })
411        .await?
412    }
413
414    /// Get the user's Tic-Tac-Toe scores
415    pub async fn get_tic_tac_toe_score(
416        &self,
417        guild_id: MaybeGuildString,
418        player: UserId,
419    ) -> anyhow::Result<TicTacToeScore> {
420        self.access_db(move |db| {
421            let txn = db.transaction()?;
422            create_user_score_data(&txn, guild_id, player)?;
423            let ret = txn.prepare_cached(GET_TIC_TAC_TOE_SCORE_SQL)?.query_row(
424                params![guild_id, i64::from(player)],
425                |row| {
426                    Ok(TicTacToeScore {
427                        wins: row.get(0)?,
428                        losses: row.get(1)?,
429                        ties: row.get(2)?,
430                        concedes: row.get(3)?,
431                    })
432                },
433            )?;
434            txn.commit().context("failed to commit").map(|_| ret)
435        })
436        .await?
437    }
438
439    /// Get the top Tic-Tac-Toe scores for the current server
440    pub async fn get_top_tic_tac_toe_scores(
441        &self,
442        guild_id: MaybeGuildString,
443    ) -> anyhow::Result<Vec<TicTacToeTopPlayerScore>> {
444        self.access_db(move |db| {
445            let ret = db
446                .prepare_cached(GET_TOP_TIC_TAC_TOE_SCORES_SQL)?
447                .query_map([guild_id], TicTacToeTopPlayerScore::from_row)?
448                .collect::<Result<Vec<_>, _>>()?;
449
450            Ok(ret)
451        })
452        .await?
453    }
454}