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 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 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 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 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 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 pub fn clock_running(&self) -> bool {
343 self.has_clock() && self.ply >= 2 && self.status.is_ongoing()
344 }
345
346 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 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 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
490pub 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
503pub 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#[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
646pub 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
655pub 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}