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
21const 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#[derive(Debug, thiserror::Error)]
43pub enum TicTacToeCreateGameError {
44 #[error("the author is in a game")]
46 AuthorInGame,
47
48 #[error("the opponent is in a game")]
50 OpponentInGame,
51
52 #[error("database error")]
54 Database(#[source] anyhow::Error),
55}
56
57#[derive(Debug, thiserror::Error)]
59pub enum TicTacToeTryMoveError {
60 #[error("not in a game")]
62 NotInAGame,
63
64 #[error("not the user's turn to move")]
66 InvalidTurn,
67
68 #[error("the move is not valid")]
70 InvalidMove,
71
72 #[error("database error")]
74 Database(#[source] anyhow::Error),
75}
76
77#[derive(Debug, Copy, Clone)]
79pub enum TicTacToeTryMoveResponse {
80 Winner {
82 game: TicTacToeGame,
83 winner: TicTacToePlayer,
84 loser: TicTacToePlayer,
85 },
86 Tie { game: TicTacToeGame },
88 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
133fn 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
145fn 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
169fn 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 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 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 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 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 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 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 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}