Skip to main content

open_chess_server/
model.rs

1//! Rows (accounts, games, challenges) and their Lichess JSON views.
2
3use anyhow::{Context, Result};
4use chess_core::{Rating, Side, Speed, Status};
5use chess_proto as p;
6use sha2::{Digest, Sha256};
7use tokio_postgres::GenericClient;
8use tokio_postgres::Row;
9
10use crate::db::Db;
11
12/// Lichess sends this for an unlimited game's clock.
13pub const NO_CLOCK_MS: i64 = 2_147_483_647;
14
15// ─── accounts ───
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Account {
19    pub player_id: i64,
20    pub username: String,
21    pub bot: bool,
22    pub marked: bool,
23    pub created_ms: i64,
24    pub seen_ms: Option<i64>,
25}
26
27impl Account {
28    /// Lichess user ids are the lowercase username.
29    pub fn id(&self) -> String {
30        self.username.to_lowercase()
31    }
32
33    pub fn title(&self) -> Option<String> {
34        self.bot.then(|| "BOT".to_owned())
35    }
36
37    fn from_row(r: &Row) -> Account {
38        Account {
39            player_id: r.get("player_id"),
40            username: r.get("username"),
41            bot: r.get::<_, String>("kind") == "bot",
42            marked: r.get::<_, Option<chrono::DateTime<chrono::Utc>>>("marked_at").is_some(),
43            created_ms: r.get::<_, chrono::DateTime<chrono::Utc>>("created_at").timestamp_millis(),
44            seen_ms: r
45                .get::<_, Option<chrono::DateTime<chrono::Utc>>>("seen_at")
46                .map(|t| t.timestamp_millis()),
47        }
48    }
49}
50
51/// A Lichess-style username from a display name: letters, digits, `_`
52/// and `-`, 2–20 characters.
53pub fn username_from(name: &str) -> String {
54    let mut u: String = name
55        .trim()
56        .chars()
57        .map(|c| if c.is_whitespace() { '-' } else { c })
58        .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
59        .collect();
60    while u.starts_with(['-', '_']) {
61        u.remove(0);
62    }
63    u.truncate(20);
64    if u.len() < 2 { format!("player{u}") } else { u }
65}
66
67const ACCOUNT_COLS: &str = "player_id, username, kind, created_at, seen_at, marked_at";
68
69pub async fn account(db: &Db, c: &impl GenericClient, player: i64) -> Result<Option<Account>> {
70    Ok(c.query_opt(
71        &db.sql(&format!("SELECT {ACCOUNT_COLS} FROM chess.accounts WHERE player_id = $1")),
72        &[&player],
73    )
74    .await?
75    .map(|r| Account::from_row(&r)))
76}
77
78pub async fn account_by_name(db: &Db, c: &impl GenericClient, name: &str) -> Result<Option<Account>> {
79    Ok(c.query_opt(
80        &db.sql(&format!("SELECT {ACCOUNT_COLS} FROM chess.accounts WHERE lower(username) = lower($1)")),
81        &[&name],
82    )
83    .await?
84    .map(|r| Account::from_row(&r)))
85}
86
87/// The player's chess account, made on first use from their site name.
88pub async fn ensure_account(db: &Db, c: &impl GenericClient, player: i64) -> Result<Account> {
89    if let Some(a) = account(db, c, player).await? {
90        return Ok(a);
91    }
92    let name: String = c
93        .query_one(&db.sql("SELECT name FROM site.players WHERE id = $1"), &[&player])
94        .await
95        .context("no such player")?
96        .get(0);
97    let base = username_from(&name);
98    for n in 0..1000 {
99        let candidate = if n == 0 { base.clone() } else { format!("{base}{n}") };
100        let r = c
101            .query_opt(
102                &db.sql(&format!(
103                    "INSERT INTO chess.accounts (player_id, username) VALUES ($1, $2)
104                     ON CONFLICT DO NOTHING RETURNING {ACCOUNT_COLS}"
105                )),
106                &[&player, &candidate],
107            )
108            .await?;
109        if let Some(r) = r {
110            return Ok(Account::from_row(&r));
111        }
112        // Lost a race for our own row, or the name is taken.
113        if let Some(a) = account(db, c, player).await? {
114            return Ok(a);
115        }
116    }
117    anyhow::bail!("couldn't find a free username for {name:?}")
118}
119
120pub async fn rating(db: &Db, c: &impl GenericClient, player: i64, perf: &str) -> Result<(Rating, i32, i32)> {
121    let r = c
122        .query_opt(
123            &db.sql(
124                "SELECT rating, deviation, volatility, games, prog FROM chess.ratings WHERE player_id = $1 AND perf = $2",
125            ),
126            &[&player, &perf],
127        )
128        .await?;
129    Ok(r.map_or((Rating::default(), 0, 0), |r| {
130        (Rating { rating: r.get(0), deviation: r.get(1), volatility: r.get(2) }, r.get(3), r.get(4))
131    }))
132}
133
134pub async fn perfs(
135    db: &Db,
136    c: &impl GenericClient,
137    player: i64,
138) -> Result<std::collections::BTreeMap<String, p::Perf>> {
139    let mut out = std::collections::BTreeMap::new();
140    for r in c
141        .query(
142            &db.sql("SELECT perf, rating, deviation, games, prog FROM chess.ratings WHERE player_id = $1"),
143            &[&player],
144        )
145        .await?
146    {
147        let rating = Rating { rating: r.get(1), deviation: r.get(2), volatility: 0.09 };
148        out.insert(
149            r.get::<_, String>(0),
150            p::Perf {
151                games: r.get(3),
152                rating: rating.int(),
153                rd: rating.deviation.round() as i32,
154                prog: r.get(4),
155                prov: rating.provisional().then_some(true),
156            },
157        );
158    }
159    Ok(out)
160}
161
162pub async fn user_json(db: &Db, c: &impl GenericClient, a: &Account, public_url: &str) -> Result<p::User> {
163    let count = c
164        .query_one(
165            &db.sql(
166                "SELECT count(*) FILTER (WHERE status NOT IN (10, 20, 25, 37)),
167                        count(*) FILTER (WHERE rated AND status NOT IN (10, 20, 25, 37)),
168                        count(*) FILTER (WHERE winner IS NOT NULL AND ((winner = 'white') = (white_id = $1))),
169                        count(*) FILTER (WHERE winner IS NOT NULL AND ((winner = 'white') <> (white_id = $1))),
170                        count(*) FILTER (WHERE winner IS NULL AND status NOT IN (10, 20, 25, 37)),
171                        count(*) FILTER (WHERE status IN (10, 20))
172                   FROM chess.games WHERE white_id = $1 OR black_id = $1",
173            ),
174            &[&a.player_id],
175        )
176        .await?;
177    Ok(p::User {
178        id: a.id(),
179        username: a.username.clone(),
180        perfs: perfs(db, c, a.player_id).await?,
181        title: a.title(),
182        created_at: a.created_ms,
183        seen_at: a.seen_ms,
184        tos_violation: a.marked.then_some(true),
185        disabled: None,
186        url: Some(format!("{public_url}/@/{}", a.username)),
187        count: Some(p::Count {
188            all: count.get(0),
189            rated: count.get(1),
190            win: count.get(2),
191            loss: count.get(3),
192            draw: count.get(4),
193            playing: count.get(5),
194            bookmark: 0,
195            import: 0,
196            me: 0,
197        }),
198    })
199}
200
201// ─── games ───
202
203#[derive(Debug, Clone)]
204pub struct GameRow {
205    pub id: String,
206    pub white: Account,
207    pub black: Account,
208    pub variant: String,
209    pub initial_fen: Option<String>,
210    pub rated: bool,
211    pub source: String,
212    pub speed: Speed,
213    pub rules: Vec<String>,
214    pub clock_initial_ms: Option<i64>,
215    pub clock_increment_ms: Option<i64>,
216    pub status: Status,
217    pub winner: Option<Side>,
218    pub moves: String,
219    pub ply: i32,
220    pub fen: String,
221    pub white_ms: Option<i64>,
222    pub black_ms: Option<i64>,
223    pub move_ms: Vec<i64>,
224    pub clock_ms: Vec<i64>,
225    pub draw_offer: Option<Side>,
226    pub takeback_offer: Option<Side>,
227    pub white_rating: i32,
228    pub black_rating: i32,
229    pub white_provisional: bool,
230    pub black_provisional: bool,
231    pub white_rating_diff: Option<i32>,
232    pub black_rating_diff: Option<i32>,
233    pub created_ms: i64,
234    pub last_move_ms: Option<i64>,
235    pub finished_ms: Option<i64>,
236    /// Milliseconds since the side to move's turn began (database clock).
237    pub elapsed_ms: i64,
238}
239
240pub const GAME_SELECT: &str = "SELECT g.*,
241        (extract(epoch from (clock_timestamp() - g.turn_started_at)) * 1000)::bigint AS elapsed_ms,
242        wa.player_id AS w_player_id, wa.username AS w_username, wa.kind AS w_kind, wa.created_at AS w_created_at,
243        wa.seen_at AS w_seen_at, wa.marked_at AS w_marked_at,
244        ba.player_id AS b_player_id, ba.username AS b_username, ba.kind AS b_kind, ba.created_at AS b_created_at,
245        ba.seen_at AS b_seen_at, ba.marked_at AS b_marked_at
246   FROM chess.games g
247   JOIN chess.accounts wa ON wa.player_id = g.white_id
248   JOIN chess.accounts ba ON ba.player_id = g.black_id";
249
250fn side_opt(s: Option<&str>) -> Option<Side> {
251    s.and_then(Side::from_name)
252}
253
254fn ms(t: Option<chrono::DateTime<chrono::Utc>>) -> Option<i64> {
255    t.map(|t| t.timestamp_millis())
256}
257
258impl GameRow {
259    pub fn from_row(r: &Row) -> GameRow {
260        let acc = |p: &str| Account {
261            player_id: r.get(format!("{p}_player_id").as_str()),
262            username: r.get(format!("{p}_username").as_str()),
263            bot: r.get::<_, String>(format!("{p}_kind").as_str()) == "bot",
264            marked: r
265                .get::<_, Option<chrono::DateTime<chrono::Utc>>>(format!("{p}_marked_at").as_str())
266                .is_some(),
267            created_ms: r
268                .get::<_, chrono::DateTime<chrono::Utc>>(format!("{p}_created_at").as_str())
269                .timestamp_millis(),
270            seen_ms: ms(r.get(format!("{p}_seen_at").as_str())),
271        };
272        GameRow {
273            id: r.get("id"),
274            white: acc("w"),
275            black: acc("b"),
276            variant: r.get("variant"),
277            initial_fen: r.get("initial_fen"),
278            rated: r.get("rated"),
279            source: r.get("source"),
280            speed: Speed::from_key(r.get::<_, &str>("speed")).unwrap_or(Speed::Classical),
281            rules: r.get("rules"),
282            clock_initial_ms: r.get("clock_initial_ms"),
283            clock_increment_ms: r.get("clock_increment_ms"),
284            status: Status::from_id(r.get("status")).unwrap_or(Status::UnknownFinish),
285            winner: side_opt(r.get("winner")),
286            moves: r.get("moves"),
287            ply: r.get("ply"),
288            fen: r.get("fen"),
289            white_ms: r.get("white_ms"),
290            black_ms: r.get("black_ms"),
291            move_ms: r.get("move_ms"),
292            clock_ms: r.get("clock_ms"),
293            draw_offer: side_opt(r.get("draw_offer")),
294            takeback_offer: side_opt(r.get("takeback_offer")),
295            white_rating: r.get("white_rating"),
296            black_rating: r.get("black_rating"),
297            white_provisional: r.get("white_provisional"),
298            black_provisional: r.get("black_provisional"),
299            white_rating_diff: r.get("white_rating_diff"),
300            black_rating_diff: r.get("black_rating_diff"),
301            created_ms: r.get::<_, chrono::DateTime<chrono::Utc>>("created_at").timestamp_millis(),
302            last_move_ms: ms(r.get("last_move_at")),
303            finished_ms: ms(r.get("finished_at")),
304            elapsed_ms: r.get("elapsed_ms"),
305        }
306    }
307
308    pub fn side_of(&self, player: i64) -> Option<Side> {
309        if self.white.player_id == player {
310            Some(Side::White)
311        } else if self.black.player_id == player {
312            Some(Side::Black)
313        } else {
314            None
315        }
316    }
317
318    pub fn player(&self, side: Side) -> &Account {
319        match side {
320            Side::White => &self.white,
321            Side::Black => &self.black,
322        }
323    }
324
325    pub fn rating(&self, side: Side) -> (i32, bool, Option<i32>) {
326        match side {
327            Side::White => (self.white_rating, self.white_provisional, self.white_rating_diff),
328            Side::Black => (self.black_rating, self.black_provisional, self.black_rating_diff),
329        }
330    }
331
332    /// Side to move, from the FEN's active colour.
333    pub fn turn(&self) -> Side {
334        if self.fen.split_whitespace().nth(1) == Some("b") { Side::Black } else { Side::White }
335    }
336
337    pub fn has_clock(&self) -> bool {
338        self.clock_initial_ms.is_some()
339    }
340
341    /// Clocks run once each side has made a first move.
342    pub fn clock_running(&self) -> bool {
343        self.has_clock() && self.ply >= 2 && self.status.is_ongoing()
344    }
345
346    /// Time left now (the side to move's clock is ticking).
347    pub fn live_ms(&self, side: Side) -> i64 {
348        if !self.has_clock() {
349            return NO_CLOCK_MS;
350        }
351        let stored = match side {
352            Side::White => self.white_ms,
353            Side::Black => self.black_ms,
354        }
355        .unwrap_or(0);
356        if self.clock_running() && self.turn() == side {
357            (stored - self.elapsed_ms.max(0)).max(0)
358        } else {
359            stored
360        }
361    }
362
363    pub fn last_move(&self) -> &str {
364        self.moves.split_whitespace().next_back().unwrap_or("")
365    }
366
367    /// Has `side` moved yet (from the start of this game)?
368    pub fn has_moved(&self, side: Side) -> bool {
369        let first =
370            chess_core::Game::new(self.initial_fen.as_deref()).map_or(Side::White, |g| g.first_to_move());
371        let mine = if side == first { 1 } else { 2 };
372        self.ply >= mine
373    }
374
375    pub fn variant(&self) -> p::Variant {
376        if self.variant == "fromPosition" { p::Variant::from_position() } else { p::Variant::standard() }
377    }
378
379    pub fn perf_key(&self) -> &'static str {
380        self.speed.key()
381    }
382
383    pub fn full_id(&self, player: i64) -> String {
384        format!("{}{}", self.id, full_id_suffix(&self.id, player))
385    }
386
387    pub fn has_rule(&self, rule: &str) -> bool {
388        self.rules.iter().any(|r| r == rule)
389    }
390
391    pub fn event_player(&self, side: Side) -> p::GameEventPlayer {
392        let a = self.player(side);
393        let (rating, prov, diff) = self.rating(side);
394        p::GameEventPlayer {
395            id: a.id(),
396            name: a.username.clone(),
397            title: a.title(),
398            rating: Some(rating),
399            rating_diff: diff,
400            provisional: prov.then_some(true),
401            ai_level: None,
402        }
403    }
404
405    pub fn state(&self, first_move_ms: i64) -> p::GameState {
406        let inc = self.clock_increment_ms.unwrap_or(0);
407        let expiration = (self.status.is_ongoing() && self.ply < 2)
408            .then_some(p::Expiration { idle_millis: self.elapsed_ms.max(0), millis_to_move: first_move_ms });
409        p::GameState {
410            kind: p::GameState::TYPE.into(),
411            moves: self.moves.clone(),
412            wtime: self.live_ms(Side::White),
413            btime: self.live_ms(Side::Black),
414            winc: inc,
415            binc: inc,
416            status: self.status,
417            winner: self.winner,
418            wdraw: self.draw_offer == Some(Side::White),
419            bdraw: self.draw_offer == Some(Side::Black),
420            wtakeback: self.takeback_offer == Some(Side::White),
421            btakeback: self.takeback_offer == Some(Side::Black),
422            expiration,
423        }
424    }
425
426    pub fn full(&self, first_move_ms: i64) -> p::GameFull {
427        p::GameFull {
428            kind: p::GameFull::TYPE.into(),
429            id: self.id.clone(),
430            variant: self.variant(),
431            clock: self
432                .clock_initial_ms
433                .map(|i| p::GameClock { initial: i, increment: self.clock_increment_ms.unwrap_or(0) }),
434            speed: self.speed,
435            perf: p::PerfName { name: self.speed.display().into() },
436            rated: self.rated,
437            created_at: self.created_ms,
438            white: self.event_player(Side::White),
439            black: self.event_player(Side::Black),
440            initial_fen: self.initial_fen.clone().unwrap_or_else(|| "startpos".into()),
441            state: self.state(first_move_ms),
442            days_per_turn: None,
443            tournament_id: None,
444        }
445    }
446
447    /// gameStart / gameFinish / nowPlaying entry, from `player`'s side.
448    pub fn event_info(&self, player: i64) -> Option<p::GameEventInfo> {
449        let side = self.side_of(player)?;
450        let opp = side.opposite();
451        let (rating, _, diff) = self.rating(side);
452        let (orating, _, odiff) = self.rating(opp);
453        let o = self.player(opp);
454        let ongoing = self.status.is_ongoing();
455        Some(p::GameEventInfo {
456            full_id: self.full_id(player),
457            game_id: self.id.clone(),
458            fen: self.fen.clone(),
459            color: side,
460            last_move: self.last_move().to_owned(),
461            source: self.source.clone(),
462            status: self.status.into(),
463            variant: self.variant().brief(),
464            speed: self.speed,
465            perf: self.perf_key().into(),
466            rated: self.rated,
467            has_moved: self.has_moved(side),
468            opponent: p::GameEventOpponent {
469                id: o.id(),
470                username: o.username.clone(),
471                rating: orating,
472                rating_diff: odiff,
473            },
474            is_my_turn: ongoing && self.turn() == side,
475            seconds_left: self.has_clock().then(|| self.live_ms(side) / 1000),
476            winner: self.winner,
477            rating: Some(rating),
478            rating_diff: diff,
479            compat: p::Compat { bot: true, board: true },
480            id: self.id.clone(),
481        })
482    }
483
484    pub fn event(&self, player: i64, kind: &str) -> Option<serde_json::Value> {
485        let game = self.event_info(player)?;
486        serde_json::to_value(p::GameEvent { kind: kind.into(), game }).ok()
487    }
488}
489
490/// A stable 4-character suffix for a player's full game id.
491pub fn full_id_suffix(game: &str, player: i64) -> String {
492    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
493    let h = Sha256::digest(format!("{game}:{player}").as_bytes());
494    h.iter().take(4).map(|b| ALPHABET[usize::from(*b) % ALPHABET.len()] as char).collect()
495}
496
497pub async fn game(db: &Db, c: &impl GenericClient, id: &str) -> Result<Option<GameRow>> {
498    Ok(c.query_opt(&db.sql(&format!("{GAME_SELECT} WHERE g.id = $1")), &[&id])
499        .await?
500        .map(|r| GameRow::from_row(&r)))
501}
502
503/// Lock the game row for an update (inside a transaction).
504pub async fn game_for_update(db: &Db, c: &impl GenericClient, id: &str) -> Result<Option<GameRow>> {
505    Ok(c.query_opt(&db.sql(&format!("{GAME_SELECT} WHERE g.id = $1 FOR UPDATE OF g")), &[&id])
506        .await?
507        .map(|r| GameRow::from_row(&r)))
508}
509
510pub async fn ongoing_games(db: &Db, c: &impl GenericClient, player: i64) -> Result<Vec<GameRow>> {
511    Ok(c.query(
512        &db.sql(&format!(
513            "{GAME_SELECT} WHERE (g.white_id = $1 OR g.black_id = $1) AND g.status IN (10, 20) ORDER BY g.created_at"
514        )),
515        &[&player],
516    )
517    .await?
518    .iter()
519    .map(GameRow::from_row)
520    .collect())
521}
522
523// ─── challenges ───
524
525#[derive(Debug, Clone)]
526pub struct ChallengeRow {
527    pub id: String,
528    pub challenger: Account,
529    pub dest: Account,
530    pub status: String,
531    pub variant: String,
532    pub initial_fen: Option<String>,
533    pub rated: bool,
534    pub clock_limit: Option<i32>,
535    pub clock_increment: Option<i32>,
536    pub color: String,
537    pub final_color: Side,
538    pub rules: Vec<String>,
539    pub decline_reason: Option<String>,
540}
541
542pub const CHALLENGE_SELECT: &str = "SELECT c.*,
543        ca.player_id AS c_player_id, ca.username AS c_username, ca.kind AS c_kind, ca.created_at AS c_created_at,
544        ca.seen_at AS c_seen_at, ca.marked_at AS c_marked_at,
545        da.player_id AS d_player_id, da.username AS d_username, da.kind AS d_kind, da.created_at AS d_created_at,
546        da.seen_at AS d_seen_at, da.marked_at AS d_marked_at
547   FROM chess.challenges c
548   JOIN chess.accounts ca ON ca.player_id = c.challenger_id
549   JOIN chess.accounts da ON da.player_id = c.dest_id";
550
551impl ChallengeRow {
552    pub fn from_row(r: &Row) -> ChallengeRow {
553        let acc = |p: &str| Account {
554            player_id: r.get(format!("{p}_player_id").as_str()),
555            username: r.get(format!("{p}_username").as_str()),
556            bot: r.get::<_, String>(format!("{p}_kind").as_str()) == "bot",
557            marked: r
558                .get::<_, Option<chrono::DateTime<chrono::Utc>>>(format!("{p}_marked_at").as_str())
559                .is_some(),
560            created_ms: r
561                .get::<_, chrono::DateTime<chrono::Utc>>(format!("{p}_created_at").as_str())
562                .timestamp_millis(),
563            seen_ms: ms(r.get(format!("{p}_seen_at").as_str())),
564        };
565        ChallengeRow {
566            id: r.get("id"),
567            challenger: acc("c"),
568            dest: acc("d"),
569            status: r.get("status"),
570            variant: r.get("variant"),
571            initial_fen: r.get("initial_fen"),
572            rated: r.get("rated"),
573            clock_limit: r.get("clock_limit"),
574            clock_increment: r.get("clock_increment"),
575            color: r.get("color"),
576            final_color: side_opt(r.get("final_color")).unwrap_or(Side::White),
577            rules: r.get("rules"),
578            decline_reason: r.get("decline_reason"),
579        }
580    }
581
582    pub fn speed(&self) -> Speed {
583        match (self.clock_limit, self.clock_increment) {
584            (Some(l), Some(i)) => Speed::from_clock(l as u32, i as u32),
585            _ => Speed::Correspondence,
586        }
587    }
588
589    pub fn json(&self, public_url: &str, viewer: Option<i64>, ratings: (i32, i32)) -> p::Challenge {
590        let user = |a: &Account, rating: i32| p::ChallengeUser {
591            id: a.id(),
592            name: a.username.clone(),
593            rating: Some(rating),
594            title: a.title(),
595            provisional: None,
596            online: None,
597        };
598        let speed = self.speed();
599        let decline_key = self.decline_reason.clone();
600        let decline_text = decline_key.as_deref().map(|k| {
601            p::DECLINE_REASONS
602                .iter()
603                .find(|(key, _)| *key == k)
604                .map_or(p::DECLINE_REASONS[0].1, |(_, t)| *t)
605                .to_owned()
606        });
607        p::Challenge {
608            id: self.id.clone(),
609            url: format!("{public_url}/{}", self.id),
610            status: self.status.clone(),
611            challenger: user(&self.challenger, ratings.0),
612            dest_user: Some(user(&self.dest, ratings.1)),
613            variant: if self.variant == "fromPosition" {
614                p::Variant::from_position()
615            } else {
616                p::Variant::standard()
617            },
618            rated: self.rated,
619            speed,
620            time_control: match (self.clock_limit, self.clock_increment) {
621                (Some(l), Some(i)) => p::TimeControl::clock(l as u32, i as u32),
622                _ => p::TimeControl::Unlimited,
623            },
624            color: self.color.clone(),
625            final_color: Some(self.final_color),
626            perf: p::ChallengePerf { icon: String::new(), name: speed.display().into() },
627            direction: viewer
628                .map(|v| if v == self.challenger.player_id { "out".into() } else { "in".into() }),
629            initial_fen: self.initial_fen.clone(),
630            decline_reason: decline_text,
631            decline_reason_key: decline_key,
632        }
633    }
634}
635
636pub async fn challenge(
637    db: &Db,
638    c: &impl GenericClient,
639    id: &str,
640    lock: bool,
641) -> Result<Option<ChallengeRow>> {
642    let q = format!("{CHALLENGE_SELECT} WHERE c.id = $1{}", if lock { " FOR UPDATE OF c" } else { "" });
643    Ok(c.query_opt(&db.sql(&q), &[&id]).await?.map(|r| ChallengeRow::from_row(&r)))
644}
645
646/// Current ratings of the challenger and destination, for the challenge's
647/// perf.
648pub async fn challenge_ratings(db: &Db, c: &impl GenericClient, ch: &ChallengeRow) -> Result<(i32, i32)> {
649    let perf = ch.speed().key();
650    let a = rating(db, c, ch.challenger.player_id, perf).await?.0.int();
651    let b = rating(db, c, ch.dest.player_id, perf).await?.0.int();
652    Ok((a, b))
653}
654
655/// An 8-character id like Lichess's.
656pub fn new_id() -> String {
657    crate::auth::random_token(8)
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn usernames() {
666        assert_eq!(username_from("Mark Olliver"), "Mark-Olliver");
667        assert_eq!(username_from("  Émile_Z! "), "mile_Z");
668        assert_eq!(username_from("x"), "playerx");
669        assert_eq!(username_from("--bob"), "bob");
670        assert_eq!(username_from("a very long display name indeed").len(), 20);
671    }
672
673    #[test]
674    fn full_ids_are_stable_and_distinct() {
675        let a = full_id_suffix("abcd1234", 1);
676        assert_eq!(a.len(), 4);
677        assert_eq!(a, full_id_suffix("abcd1234", 1));
678        assert_ne!(a, full_id_suffix("abcd1234", 2));
679    }
680}