1use 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
12pub const NO_CLOCK_MS: i64 = 2_147_483_647;
14
15#[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 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
51pub 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
87pub 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 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#[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 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 pub feed_ms: Option<i64>,
241 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 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 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 pub fn clock_running(&self) -> bool {
356 self.has_clock() && self.ply >= 2 && self.status.is_ongoing()
357 }
358
359 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 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 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
507pub 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
520pub 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#[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 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
667pub 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
676pub 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}