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 chess.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    /// Correspondence: days per move.
217    pub days_per_turn: Option<i32>,
218    pub status: Status,
219    pub winner: Option<Side>,
220    pub moves: String,
221    pub ply: i32,
222    pub fen: String,
223    pub white_ms: Option<i64>,
224    pub black_ms: Option<i64>,
225    pub move_ms: Vec<i64>,
226    pub clock_ms: Vec<i64>,
227    pub draw_offer: Option<Side>,
228    pub takeback_offer: Option<Side>,
229    pub white_rating: i32,
230    pub black_rating: i32,
231    pub white_provisional: bool,
232    pub black_provisional: bool,
233    pub white_rating_diff: Option<i32>,
234    pub black_rating_diff: Option<i32>,
235    pub created_ms: i64,
236    pub last_move_ms: Option<i64>,
237    pub finished_ms: Option<i64>,
238    /// When the server recorded it finished (the feed's order): the finish
239    /// itself for games played here, the upload for games played offline.
240    pub feed_ms: Option<i64>,
241    /// Milliseconds since the side to move's turn began (database clock).
242    pub elapsed_ms: i64,
243}
244
245pub const GAME_SELECT: &str = "SELECT g.*,
246        (extract(epoch from (clock_timestamp() - g.turn_started_at)) * 1000)::bigint AS elapsed_ms,
247        wa.player_id AS w_player_id, wa.username AS w_username, wa.kind AS w_kind, wa.created_at AS w_created_at,
248        wa.seen_at AS w_seen_at, wa.marked_at AS w_marked_at,
249        ba.player_id AS b_player_id, ba.username AS b_username, ba.kind AS b_kind, ba.created_at AS b_created_at,
250        ba.seen_at AS b_seen_at, ba.marked_at AS b_marked_at
251   FROM chess.games g
252   JOIN chess.accounts wa ON wa.player_id = g.white_id
253   JOIN chess.accounts ba ON ba.player_id = g.black_id";
254
255fn side_opt(s: Option<&str>) -> Option<Side> {
256    s.and_then(Side::from_name)
257}
258
259fn ms(t: Option<chrono::DateTime<chrono::Utc>>) -> Option<i64> {
260    t.map(|t| t.timestamp_millis())
261}
262
263impl GameRow {
264    pub fn from_row(r: &Row) -> GameRow {
265        let acc = |p: &str| Account {
266            player_id: r.get(format!("{p}_player_id").as_str()),
267            username: r.get(format!("{p}_username").as_str()),
268            bot: r.get::<_, String>(format!("{p}_kind").as_str()) == "bot",
269            marked: r
270                .get::<_, Option<chrono::DateTime<chrono::Utc>>>(format!("{p}_marked_at").as_str())
271                .is_some(),
272            created_ms: r
273                .get::<_, chrono::DateTime<chrono::Utc>>(format!("{p}_created_at").as_str())
274                .timestamp_millis(),
275            seen_ms: ms(r.get(format!("{p}_seen_at").as_str())),
276        };
277        GameRow {
278            id: r.get("id"),
279            white: acc("w"),
280            black: acc("b"),
281            variant: r.get("variant"),
282            initial_fen: r.get("initial_fen"),
283            rated: r.get("rated"),
284            source: r.get("source"),
285            speed: Speed::from_key(r.get::<_, &str>("speed")).unwrap_or(Speed::Classical),
286            rules: r.get("rules"),
287            clock_initial_ms: r.get("clock_initial_ms"),
288            clock_increment_ms: r.get("clock_increment_ms"),
289            days_per_turn: r.get("days_per_turn"),
290            status: Status::from_id(r.get("status")).unwrap_or(Status::UnknownFinish),
291            winner: side_opt(r.get("winner")),
292            moves: r.get("moves"),
293            ply: r.get("ply"),
294            fen: r.get("fen"),
295            white_ms: r.get("white_ms"),
296            black_ms: r.get("black_ms"),
297            move_ms: r.get("move_ms"),
298            clock_ms: r.get("clock_ms"),
299            draw_offer: side_opt(r.get("draw_offer")),
300            takeback_offer: side_opt(r.get("takeback_offer")),
301            white_rating: r.get("white_rating"),
302            black_rating: r.get("black_rating"),
303            white_provisional: r.get("white_provisional"),
304            black_provisional: r.get("black_provisional"),
305            white_rating_diff: r.get("white_rating_diff"),
306            black_rating_diff: r.get("black_rating_diff"),
307            created_ms: r.get::<_, chrono::DateTime<chrono::Utc>>("created_at").timestamp_millis(),
308            last_move_ms: ms(r.get("last_move_at")),
309            finished_ms: ms(r.get("finished_at")),
310            feed_ms: ms(r.get("feed_at")),
311            elapsed_ms: r.get("elapsed_ms"),
312        }
313    }
314
315    pub fn side_of(&self, player: i64) -> Option<Side> {
316        if self.white.player_id == player {
317            Some(Side::White)
318        } else if self.black.player_id == player {
319            Some(Side::Black)
320        } else {
321            None
322        }
323    }
324
325    pub fn player(&self, side: Side) -> &Account {
326        match side {
327            Side::White => &self.white,
328            Side::Black => &self.black,
329        }
330    }
331
332    pub fn rating(&self, side: Side) -> (i32, bool, Option<i32>) {
333        match side {
334            Side::White => (self.white_rating, self.white_provisional, self.white_rating_diff),
335            Side::Black => (self.black_rating, self.black_provisional, self.black_rating_diff),
336        }
337    }
338
339    /// Side to move, from the FEN's active colour.
340    pub fn turn(&self) -> Side {
341        if self.fen.split_whitespace().nth(1) == Some("b") { Side::Black } else { Side::White }
342    }
343
344    pub fn has_clock(&self) -> bool {
345        self.clock_initial_ms.is_some()
346    }
347
348    /// Correspondence: what's left of the side to move's days (ms).
349    pub fn days_left_ms(&self) -> Option<i64> {
350        let days = i64::from(self.days_per_turn?);
351        self.status.is_ongoing().then(|| (days * 86_400_000 - self.elapsed_ms).max(0))
352    }
353
354    /// Clocks run once each side has made a first move.
355    pub fn clock_running(&self) -> bool {
356        self.has_clock() && self.ply >= 2 && self.status.is_ongoing()
357    }
358
359    /// Time left now (the side to move's clock is ticking).
360    pub fn live_ms(&self, side: Side) -> i64 {
361        if !self.has_clock() {
362            return NO_CLOCK_MS;
363        }
364        let stored = match side {
365            Side::White => self.white_ms,
366            Side::Black => self.black_ms,
367        }
368        .unwrap_or(0);
369        if self.clock_running() && self.turn() == side {
370            (stored - self.elapsed_ms.max(0)).max(0)
371        } else {
372            stored
373        }
374    }
375
376    pub fn last_move(&self) -> &str {
377        self.moves.split_whitespace().next_back().unwrap_or("")
378    }
379
380    /// Has `side` moved yet (from the start of this game)?
381    pub fn has_moved(&self, side: Side) -> bool {
382        let first =
383            chess_core::Game::new(self.initial_fen.as_deref()).map_or(Side::White, |g| g.first_to_move());
384        let mine = if side == first { 1 } else { 2 };
385        self.ply >= mine
386    }
387
388    pub fn variant(&self) -> p::Variant {
389        if self.variant == "fromPosition" { p::Variant::from_position() } else { p::Variant::standard() }
390    }
391
392    pub fn perf_key(&self) -> &'static str {
393        self.speed.key()
394    }
395
396    pub fn full_id(&self, player: i64) -> String {
397        format!("{}{}", self.id, full_id_suffix(&self.id, player))
398    }
399
400    pub fn has_rule(&self, rule: &str) -> bool {
401        self.rules.iter().any(|r| r == rule)
402    }
403
404    pub fn event_player(&self, side: Side) -> p::GameEventPlayer {
405        let a = self.player(side);
406        let (rating, prov, diff) = self.rating(side);
407        p::GameEventPlayer {
408            id: a.id(),
409            name: a.username.clone(),
410            title: a.title(),
411            rating: Some(rating),
412            rating_diff: diff,
413            provisional: prov.then_some(true),
414            ai_level: None,
415        }
416    }
417
418    pub fn state(&self, first_move_ms: i64) -> p::GameState {
419        let inc = self.clock_increment_ms.unwrap_or(0);
420        let expiration = (self.status.is_ongoing() && self.ply < 2)
421            .then_some(p::Expiration { idle_millis: self.elapsed_ms.max(0), millis_to_move: first_move_ms });
422        p::GameState {
423            kind: p::GameState::TYPE.into(),
424            moves: self.moves.clone(),
425            wtime: self.live_ms(Side::White),
426            btime: self.live_ms(Side::Black),
427            winc: inc,
428            binc: inc,
429            status: self.status,
430            winner: self.winner,
431            wdraw: self.draw_offer == Some(Side::White),
432            bdraw: self.draw_offer == Some(Side::Black),
433            wtakeback: self.takeback_offer == Some(Side::White),
434            btakeback: self.takeback_offer == Some(Side::Black),
435            expiration,
436        }
437    }
438
439    pub fn full(&self, first_move_ms: i64) -> p::GameFull {
440        p::GameFull {
441            kind: p::GameFull::TYPE.into(),
442            id: self.id.clone(),
443            variant: self.variant(),
444            clock: self
445                .clock_initial_ms
446                .map(|i| p::GameClock { initial: i, increment: self.clock_increment_ms.unwrap_or(0) }),
447            speed: self.speed,
448            perf: p::PerfName { name: self.speed.display().into() },
449            rated: self.rated,
450            created_at: self.created_ms,
451            white: self.event_player(Side::White),
452            black: self.event_player(Side::Black),
453            initial_fen: self.initial_fen.clone().unwrap_or_else(|| "startpos".into()),
454            state: self.state(first_move_ms),
455            days_per_turn: self.days_per_turn,
456            tournament_id: None,
457        }
458    }
459
460    /// gameStart / gameFinish / nowPlaying entry, from `player`'s side.
461    pub fn event_info(&self, player: i64) -> Option<p::GameEventInfo> {
462        let side = self.side_of(player)?;
463        let opp = side.opposite();
464        let (rating, _, diff) = self.rating(side);
465        let (orating, _, odiff) = self.rating(opp);
466        let o = self.player(opp);
467        let ongoing = self.status.is_ongoing();
468        Some(p::GameEventInfo {
469            full_id: self.full_id(player),
470            game_id: self.id.clone(),
471            fen: self.fen.clone(),
472            color: side,
473            last_move: self.last_move().to_owned(),
474            source: self.source.clone(),
475            status: self.status.into(),
476            variant: self.variant().brief(),
477            speed: self.speed,
478            perf: self.perf_key().into(),
479            rated: self.rated,
480            has_moved: self.has_moved(side),
481            opponent: p::GameEventOpponent {
482                id: o.id(),
483                username: o.username.clone(),
484                rating: orating,
485                rating_diff: odiff,
486            },
487            is_my_turn: ongoing && self.turn() == side,
488            seconds_left: if self.has_clock() {
489                Some(self.live_ms(side) / 1000)
490            } else {
491                self.days_left_ms().map(|ms| ms / 1000)
492            },
493            winner: self.winner,
494            rating: Some(rating),
495            rating_diff: diff,
496            compat: p::Compat { bot: true, board: true },
497            id: self.id.clone(),
498        })
499    }
500
501    pub fn event(&self, player: i64, kind: &str) -> Option<serde_json::Value> {
502        let game = self.event_info(player)?;
503        serde_json::to_value(p::GameEvent { kind: kind.into(), game }).ok()
504    }
505}
506
507/// A stable 4-character suffix for a player's full game id.
508pub fn full_id_suffix(game: &str, player: i64) -> String {
509    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
510    let h = Sha256::digest(format!("{game}:{player}").as_bytes());
511    h.iter().take(4).map(|b| ALPHABET[usize::from(*b) % ALPHABET.len()] as char).collect()
512}
513
514pub async fn game(db: &Db, c: &impl GenericClient, id: &str) -> Result<Option<GameRow>> {
515    Ok(c.query_opt(&db.sql(&format!("{GAME_SELECT} WHERE g.id = $1")), &[&id])
516        .await?
517        .map(|r| GameRow::from_row(&r)))
518}
519
520/// Lock the game row for an update (inside a transaction).
521pub async fn game_for_update(db: &Db, c: &impl GenericClient, id: &str) -> Result<Option<GameRow>> {
522    Ok(c.query_opt(&db.sql(&format!("{GAME_SELECT} WHERE g.id = $1 FOR UPDATE OF g")), &[&id])
523        .await?
524        .map(|r| GameRow::from_row(&r)))
525}
526
527pub async fn ongoing_games(db: &Db, c: &impl GenericClient, player: i64) -> Result<Vec<GameRow>> {
528    Ok(c.query(
529        &db.sql(&format!(
530            "{GAME_SELECT} WHERE (g.white_id = $1 OR g.black_id = $1) AND g.status IN (10, 20) ORDER BY g.created_at"
531        )),
532        &[&player],
533    )
534    .await?
535    .iter()
536    .map(GameRow::from_row)
537    .collect())
538}
539
540// ─── challenges ───
541
542#[derive(Debug, Clone)]
543pub struct ChallengeRow {
544    pub id: String,
545    pub challenger: Account,
546    pub dest: Account,
547    pub status: String,
548    pub variant: String,
549    pub initial_fen: Option<String>,
550    pub rated: bool,
551    pub clock_limit: Option<i32>,
552    pub clock_increment: Option<i32>,
553    /// Correspondence: days per move.
554    pub days: Option<i32>,
555    pub color: String,
556    pub final_color: Side,
557    pub rules: Vec<String>,
558    pub decline_reason: Option<String>,
559}
560
561pub const CHALLENGE_SELECT: &str = "SELECT c.*,
562        ca.player_id AS c_player_id, ca.username AS c_username, ca.kind AS c_kind, ca.created_at AS c_created_at,
563        ca.seen_at AS c_seen_at, ca.marked_at AS c_marked_at,
564        da.player_id AS d_player_id, da.username AS d_username, da.kind AS d_kind, da.created_at AS d_created_at,
565        da.seen_at AS d_seen_at, da.marked_at AS d_marked_at
566   FROM chess.challenges c
567   JOIN chess.accounts ca ON ca.player_id = c.challenger_id
568   JOIN chess.accounts da ON da.player_id = c.dest_id";
569
570impl ChallengeRow {
571    pub fn from_row(r: &Row) -> ChallengeRow {
572        let acc = |p: &str| Account {
573            player_id: r.get(format!("{p}_player_id").as_str()),
574            username: r.get(format!("{p}_username").as_str()),
575            bot: r.get::<_, String>(format!("{p}_kind").as_str()) == "bot",
576            marked: r
577                .get::<_, Option<chrono::DateTime<chrono::Utc>>>(format!("{p}_marked_at").as_str())
578                .is_some(),
579            created_ms: r
580                .get::<_, chrono::DateTime<chrono::Utc>>(format!("{p}_created_at").as_str())
581                .timestamp_millis(),
582            seen_ms: ms(r.get(format!("{p}_seen_at").as_str())),
583        };
584        ChallengeRow {
585            id: r.get("id"),
586            challenger: acc("c"),
587            dest: acc("d"),
588            status: r.get("status"),
589            variant: r.get("variant"),
590            initial_fen: r.get("initial_fen"),
591            rated: r.get("rated"),
592            clock_limit: r.get("clock_limit"),
593            clock_increment: r.get("clock_increment"),
594            days: r.get("days"),
595            color: r.get("color"),
596            final_color: side_opt(r.get("final_color")).unwrap_or(Side::White),
597            rules: r.get("rules"),
598            decline_reason: r.get("decline_reason"),
599        }
600    }
601
602    pub fn speed(&self) -> Speed {
603        match (self.clock_limit, self.clock_increment) {
604            (Some(l), Some(i)) => Speed::from_clock(l as u32, i as u32),
605            _ => Speed::Correspondence,
606        }
607    }
608
609    pub fn json(&self, public_url: &str, viewer: Option<i64>, ratings: (i32, i32)) -> p::Challenge {
610        let user = |a: &Account, rating: i32| p::ChallengeUser {
611            id: a.id(),
612            name: a.username.clone(),
613            rating: Some(rating),
614            title: a.title(),
615            provisional: None,
616            online: None,
617        };
618        let speed = self.speed();
619        let decline_key = self.decline_reason.clone();
620        let decline_text = decline_key.as_deref().map(|k| {
621            p::DECLINE_REASONS
622                .iter()
623                .find(|(key, _)| *key == k)
624                .map_or(p::DECLINE_REASONS[0].1, |(_, t)| *t)
625                .to_owned()
626        });
627        p::Challenge {
628            id: self.id.clone(),
629            url: format!("{public_url}/{}", self.id),
630            status: self.status.clone(),
631            challenger: user(&self.challenger, ratings.0),
632            dest_user: Some(user(&self.dest, ratings.1)),
633            variant: if self.variant == "fromPosition" {
634                p::Variant::from_position()
635            } else {
636                p::Variant::standard()
637            },
638            rated: self.rated,
639            speed,
640            time_control: match (self.clock_limit, self.clock_increment, self.days) {
641                (Some(l), Some(i), _) => p::TimeControl::clock(l as u32, i as u32),
642                (_, _, Some(d)) => p::TimeControl::Correspondence { days_per_turn: d as u32 },
643                _ => p::TimeControl::Unlimited,
644            },
645            color: self.color.clone(),
646            final_color: Some(self.final_color),
647            perf: p::ChallengePerf { icon: String::new(), name: speed.display().into() },
648            direction: viewer
649                .map(|v| if v == self.challenger.player_id { "out".into() } else { "in".into() }),
650            initial_fen: self.initial_fen.clone(),
651            decline_reason: decline_text,
652            decline_reason_key: decline_key,
653        }
654    }
655}
656
657pub async fn challenge(
658    db: &Db,
659    c: &impl GenericClient,
660    id: &str,
661    lock: bool,
662) -> Result<Option<ChallengeRow>> {
663    let q = format!("{CHALLENGE_SELECT} WHERE c.id = $1{}", if lock { " FOR UPDATE OF c" } else { "" });
664    Ok(c.query_opt(&db.sql(&q), &[&id]).await?.map(|r| ChallengeRow::from_row(&r)))
665}
666
667/// Current ratings of the challenger and destination, for the challenge's
668/// perf.
669pub async fn challenge_ratings(db: &Db, c: &impl GenericClient, ch: &ChallengeRow) -> Result<(i32, i32)> {
670    let perf = ch.speed().key();
671    let a = rating(db, c, ch.challenger.player_id, perf).await?.0.int();
672    let b = rating(db, c, ch.dest.player_id, perf).await?.0.int();
673    Ok((a, b))
674}
675
676/// An 8-character id like Lichess's.
677pub fn new_id() -> String {
678    crate::auth::random_token(8)
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn usernames() {
687        assert_eq!(username_from("Mark Olliver"), "Mark-Olliver");
688        assert_eq!(username_from("  Émile_Z! "), "mile_Z");
689        assert_eq!(username_from("x"), "playerx");
690        assert_eq!(username_from("--bob"), "bob");
691        assert_eq!(username_from("a very long display name indeed").len(), 20);
692    }
693
694    #[test]
695    fn full_ids_are_stable_and_distinct() {
696        let a = full_id_suffix("abcd1234", 1);
697        assert_eq!(a.len(), 4);
698        assert_eq!(a, full_id_suffix("abcd1234", 1));
699        assert_ne!(a, full_id_suffix("abcd1234", 2));
700    }
701}