Skip to main content

freezeout_server/
db.rs

1// Copyright (C) 2025 Vince Vasta
2// SPDX-License-Identifier: Apache-2.0
3
4//! Database types for persisting state.
5use anyhow::{Result, bail};
6use parking_lot::Mutex;
7use rusqlite::{Connection, params};
8use std::{path::Path, sync::Arc};
9
10use freezeout_core::{crypto::PeerId, poker::Chips};
11
12/// A database player row.
13#[derive(Debug)]
14pub struct Player {
15    /// The player id.
16    pub player_id: PeerId,
17    /// The player nickname.
18    pub nickname: String,
19    /// The player chips.
20    pub chips: Chips,
21}
22
23/// Database for persisting game and players state.
24#[derive(Debug, Clone)]
25pub struct Db {
26    conn: Arc<Mutex<Connection>>,
27}
28
29impl Db {
30    /// Open a database at the given path.
31    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
32        let conn = Connection::open(path)?;
33
34        Self::init_database(&conn)?;
35
36        Ok(Db {
37            conn: Arc::new(Mutex::new(conn)),
38        })
39    }
40
41    /// Open an in memory database.
42    pub fn open_in_memory() -> Result<Self> {
43        let conn = Connection::open_in_memory()?;
44
45        Self::init_database(&conn)?;
46
47        Ok(Db {
48            conn: Arc::new(Mutex::new(conn)),
49        })
50    }
51
52    fn init_database(conn: &Connection) -> Result<()> {
53        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
54
55        // Create tables
56        conn.execute(
57            "CREATE TABLE IF NOT EXISTS players (
58               id TEXT PRIMARY KEY,
59               nickname TEXT NOT NULL,
60               chips INTEGER NOT NULL,
61               created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
62               last_update DATETIME DEFAULT CURRENT_TIMESTAMP
63            )",
64            (),
65        )?;
66
67        Ok(())
68    }
69
70    /// A player join the server.
71    ///
72    /// If the player doesn't exist it creates one with the given chips, for now if
73    /// the player exists but has fewer chips than join chips the chips are updated
74    /// so that the player has enough chips to join.
75    pub async fn join_server(
76        &self,
77        player_id: PeerId,
78        nickname: &str,
79        join_chips: Chips,
80    ) -> Result<Player> {
81        let conn = self.conn.clone();
82        let nickname = nickname.to_string();
83
84        tokio::task::spawn_blocking(move || {
85            let conn = conn.lock();
86
87            let mut stmt = conn.prepare(
88                "SELECT id, nickname, chips
89                 FROM players
90                 WHERE id = ?1",
91            )?;
92
93            let res = stmt.query_row(params![player_id.digits()], |row| {
94                Ok(Player {
95                    player_id: player_id.clone(),
96                    nickname: row.get(1)?,
97                    chips: Chips::from(row.get::<usize, i32>(2)? as u32),
98                })
99            });
100
101            match res {
102                Ok(mut player) => {
103                    let mut do_update = false;
104
105                    // Reset player chips if less than join chips.
106                    if player.chips < join_chips {
107                        player.chips = join_chips;
108                        do_update = true;
109                    }
110
111                    // Update nickname if the player joined with a different one.
112                    if player.nickname != nickname {
113                        player.nickname = nickname.to_string();
114                        do_update = true;
115                    }
116
117                    if do_update {
118                        conn.execute(
119                            "UPDATE players SET
120                                chips = ?2,
121                                nickname = ?3,
122                                last_update = CURRENT_TIMESTAMP
123                             WHERE id = ?1",
124                            params![
125                                player.player_id.digits(),
126                                player.chips.amount(),
127                                player.nickname
128                            ],
129                        )?;
130                    }
131
132                    Ok(player)
133                }
134                Err(rusqlite::Error::QueryReturnedNoRows) => {
135                    // If this is a new player add it to the database.
136                    let player = Player {
137                        player_id,
138                        nickname: nickname.to_string(),
139                        chips: join_chips,
140                    };
141
142                    conn.execute(
143                        "INSERT INTO players (id, nickname, chips, last_update)
144                         VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP)",
145                        params![player.player_id.digits(), nickname, player.chips.amount()],
146                    )?;
147
148                    Ok(player)
149                }
150                Err(e) => Err(e.into()),
151            }
152        })
153        .await?
154    }
155
156    /// Pay an amount of chips from a player.
157    ///
158    /// Returns Ok(false) if the player doesn't have enough chips or an error if the
159    /// player cannot be found.
160    pub async fn pay_from_player(&self, player_id: PeerId, amount: Chips) -> Result<bool> {
161        let conn = self.conn.clone();
162
163        tokio::task::spawn_blocking(move || {
164            let conn = conn.lock();
165
166            let mut stmt = conn.prepare("SELECT chips FROM players WHERE id = ?1")?;
167            let res = stmt.query_row(params![player_id.digits()], |row| {
168                Ok(Chips::from(row.get::<usize, i32>(0)? as u32))
169            });
170
171            match res {
172                Ok(chips) => {
173                    if chips < amount {
174                        return Ok(false);
175                    }
176
177                    let remaining_chips = chips - amount;
178
179                    // Update chips for this player.
180                    conn.execute(
181                        "UPDATE players SET
182                           chips = ?2,
183                           last_update = CURRENT_TIMESTAMP
184                         WHERE id = ?1",
185                        params![player_id.digits(), remaining_chips.amount(),],
186                    )?;
187
188                    Ok(true)
189                }
190                Err(e) => Err(e.into()),
191            }
192        })
193        .await?
194    }
195
196    /// Pay an amount of chips to a player.
197    ///
198    /// Returns an error if the player has not been found.
199    pub async fn pay_to_player(&self, player_id: PeerId, amount: Chips) -> Result<()> {
200        let conn = self.conn.clone();
201
202        tokio::task::spawn_blocking(move || {
203            let conn = conn.lock();
204
205            let num_rows = conn.execute(
206                "UPDATE players SET
207                   chips = chips + ?2,
208                   last_update = CURRENT_TIMESTAMP
209                 WHERE id = ?1",
210                params![player_id.digits(), amount.amount(),],
211            )?;
212
213            if num_rows == 0 {
214                bail!("Player {player_id} not found");
215            } else {
216                Ok(())
217            }
218        })
219        .await?
220    }
221
222    /// Returns the player with the given id.
223    pub async fn get_player(&self, player_id: PeerId) -> Result<Player> {
224        let conn = self.conn.clone();
225
226        tokio::task::spawn_blocking(move || {
227            let conn = conn.lock();
228
229            let mut stmt = conn.prepare(
230                "SELECT id, nickname, chips
231                 FROM players
232                 WHERE id = ?1",
233            )?;
234
235            stmt.query_row(params![player_id.digits()], |row| {
236                Ok(Player {
237                    player_id: player_id.clone(),
238                    nickname: row.get(1)?,
239                    chips: Chips::from(row.get::<usize, i32>(2)? as u32),
240                })
241            })
242            .map_err(anyhow::Error::from)
243        })
244        .await?
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use freezeout_core::crypto::SigningKey;
252
253    #[tokio::test]
254    async fn join_server() {
255        const JOIN_CHIPS: Chips = Chips::new(1_000_000);
256        const NICKNAME: &str = "alice";
257
258        let db = Db::open_in_memory().unwrap();
259        let player_id = SigningKey::default().verifying_key().peer_id();
260
261        // Test new player.
262        let player = db
263            .join_server(player_id.clone(), NICKNAME, JOIN_CHIPS)
264            .await
265            .unwrap();
266
267        assert_eq!(player.chips, JOIN_CHIPS);
268        assert_eq!(player.nickname, NICKNAME);
269
270        // Update nickname.
271        let player = db
272            .join_server(player_id.clone(), "bob", JOIN_CHIPS)
273            .await
274            .unwrap();
275        assert_eq!(player.nickname, "bob");
276
277        // Update chips.
278        let player = db
279            .join_server(player_id.clone(), NICKNAME, JOIN_CHIPS * 2)
280            .await
281            .unwrap();
282        assert_eq!(player.chips, JOIN_CHIPS * 2);
283
284        // Get the player.
285        let player = db.get_player(player_id).await.unwrap();
286        assert_eq!(player.chips, JOIN_CHIPS * 2);
287        assert_eq!(player.nickname, NICKNAME);
288    }
289
290    #[tokio::test]
291    async fn pay_player() {
292        const JOIN_CHIPS: Chips = Chips::new(1_000_000);
293        const NICKNAME: &str = "alice";
294
295        let db = Db::open_in_memory().unwrap();
296        let player_id = SigningKey::default().verifying_key().peer_id();
297
298        // Create a new player.
299        db.join_server(player_id.clone(), NICKNAME, JOIN_CHIPS)
300            .await
301            .unwrap();
302
303        // Give player 2 x JOIN_CHIPS.
304        db.pay_to_player(player_id.clone(), JOIN_CHIPS * 2)
305            .await
306            .unwrap();
307
308        // Check db has updated.
309        let player = db.get_player(player_id.clone()).await.unwrap();
310        // Player created with JOIN_CHIPS and we payed 2 x JOIN_CHIPS
311        assert_eq!(player.chips, JOIN_CHIPS * 3);
312
313        // Pay from player.
314        let has_chips = db
315            .pay_from_player(player_id.clone(), JOIN_CHIPS)
316            .await
317            .unwrap();
318        assert!(has_chips);
319
320        // We payed JOIN_CHIPS so we should have left 2 x JOIN_CHIPS.
321        let player = db.get_player(player_id.clone()).await.unwrap();
322        assert_eq!(player.chips, JOIN_CHIPS * 2);
323
324        // Pay remaining chips.
325        let has_chips = db
326            .pay_from_player(player_id.clone(), JOIN_CHIPS * 2)
327            .await
328            .unwrap();
329        assert!(has_chips);
330
331        // Now we cannot pay anymore as we run out of chips.
332        let has_chips = db
333            .pay_from_player(player_id.clone(), JOIN_CHIPS)
334            .await
335            .unwrap();
336        assert!(!has_chips);
337    }
338}