Skip to main content

open_chess_server/
play.rs

1//! Playing a game: moves, resignations, draws, takebacks, aborts, claims,
2//! the end of a game (ratings, notifications) and the sweeper that flags
3//! clocks and lapses challenges.
4//!
5//! Every action locks the game row (`FOR UPDATE`) in a transaction, works
6//! out the new state with chess-core, writes it and notifies in the same
7//! transaction. Postgres is the only clock (`clock_timestamp()`), so any
8//! replica can take any request and they all agree on time used.
9
10use anyhow::Result;
11use chess_core::{Clock, Game, GameScore, Side, Status};
12use tokio_postgres::GenericClient;
13use tokio_postgres::Transaction;
14use tracing::{debug, warn};
15
16use crate::app::Ctx;
17use crate::db::{Note, notify};
18use crate::error::{ApiError, ApiResult};
19use crate::model::{self, Account, ChallengeRow, GameRow};
20
21/// Board API (people) or Bot API (bot accounts).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Api {
24    Board,
25    Bot,
26}
27
28impl Api {
29    pub fn check(self, a: &Account) -> ApiResult<()> {
30        match (self, a.bot) {
31            (Api::Board, true) => {
32                Err(ApiError::bad("This endpoint can only be used with a Board API (non-bot) account"))
33            }
34            (Api::Bot, false) => Err(ApiError::bad(
35                "This endpoint can only be used with a Bot account. See /api#tag/Bot/operation/botAccountUpgrade",
36            )),
37            _ => Ok(()),
38        }
39    }
40}
41
42fn side_str(s: Option<Side>) -> Option<&'static str> {
43    s.map(Side::name)
44}
45
46/// Lock an ongoing game the player is in, checking the API matches their
47/// account kind.
48async fn lock_playing(
49    ctx: &Ctx,
50    tx: &Transaction<'_>,
51    id: &str,
52    player: i64,
53    api: Api,
54) -> ApiResult<(GameRow, Side)> {
55    let row = model::game_for_update(&ctx.db, tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
56    let side = row.side_of(player).ok_or_else(|| ApiError::not_found("game"))?;
57    api.check(row.player(side))?;
58    if !row.status.is_ongoing() {
59        return Err(ApiError::not_your_turn());
60    }
61    Ok((row, side))
62}
63
64fn replay(row: &GameRow) -> Result<Game> {
65    Game::from_moves(row.initial_fen.as_deref(), &row.moves)
66        .map_err(|e| anyhow::anyhow!("game {} doesn't replay: {e}", row.id))
67}
68
69pub async fn make_move(
70    ctx: &Ctx,
71    id: &str,
72    player: i64,
73    api: Api,
74    uci: &str,
75    offering_draw: bool,
76) -> ApiResult<()> {
77    let mut c = ctx.db.conn().await?;
78    let tx = c.transaction().await?;
79    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
80    let mut game = replay(&row)?;
81    if game.turn() != side {
82        return Err(ApiError::not_your_turn());
83    }
84    // Out of time (or never started) before this move arrived?
85    if let Some(end) = timed_out(ctx, &row, &game) {
86        finish(ctx, &tx, &row, end.0, end.1).await?;
87        tx.commit().await?;
88        return Err(ApiError::not_your_turn());
89    }
90    let mv = game.play_uci(uci).map_err(|e| ApiError::bad(e.to_string()))?;
91    let ply = row.ply as usize;
92    let mut move_ms = row.move_ms.clone();
93    let mut clock_ms = row.clock_ms.clone();
94    let (mut white_ms, mut black_ms) = (row.white_ms, row.black_ms);
95    if let (Some(init), Some(inc)) = (row.clock_initial_ms, row.clock_increment_ms) {
96        let mut clock = Clock {
97            initial_ms: init,
98            increment_ms: inc,
99            remaining_ms: [white_ms.unwrap_or(init), black_ms.unwrap_or(init)],
100        };
101        // Each side's first move is free: clocks start once both have
102        // moved, and there's no increment for it either.
103        let used =
104            if ply >= 2 { clock.apply_move(side, row.elapsed_ms, ctx.cfg.lag_comp_ms).used_ms } else { 0 };
105        move_ms.push(used);
106        clock_ms.push(clock.remaining(side));
107        white_ms = Some(clock.remaining(Side::White));
108        black_ms = Some(clock.remaining(Side::Black));
109    } else {
110        move_ms.push(row.elapsed_ms.max(0));
111        clock_ms.push(model::NO_CLOCK_MS);
112    }
113    // Draw offers: moving past the opponent's offer declines it; a move
114    // "offering" a draw while the opponent offers one agrees to it.
115    let opp_offered = row.draw_offer == Some(side.opposite());
116    let mut draw_offer = row.draw_offer.filter(|s| *s == side);
117    let mut agreed = false;
118    if offering_draw {
119        if opp_offered {
120            agreed = true;
121        } else if !(row.has_rule("noEarlyDraw") && ply < 60) {
122            draw_offer = Some(side);
123        }
124    }
125    let moves = if row.moves.is_empty() { mv.uci.clone() } else { format!("{} {}", row.moves, mv.uci) };
126    tx.execute(
127        &ctx.db.sql(
128            "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, white_ms = $5, black_ms = $6, move_ms = $7,
129                    clock_ms = $8, draw_offer = $9, takeback_offer = NULL,
130                    turn_started_at = clock_timestamp(), last_move_at = clock_timestamp()
131              WHERE id = $1",
132        ),
133        &[
134            &row.id,
135            &moves,
136            &(row.ply + 1),
137            &game.fen(),
138            &white_ms,
139            &black_ms,
140            &move_ms,
141            &clock_ms,
142            &side_str(draw_offer),
143        ],
144    )
145    .await?;
146    ctx.metrics.moves.inc();
147    let end = game.outcome().map(|o| (o.status, o.winner)).or(agreed.then_some((Status::Draw, None)));
148    if let Some((status, winner)) = end {
149        let row = model::game(&ctx.db, &tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
150        finish(ctx, &tx, &row, status, winner).await?;
151    } else {
152        notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
153    }
154    tx.commit().await?;
155    debug!(game = id, uci = mv.uci, "move");
156    Ok(())
157}
158
159/// The game is over on time before anything else can happen: a flag (the
160/// side to move's clock ran out) or a first move that never came.
161fn timed_out(ctx: &Ctx, row: &GameRow, game: &Game) -> Option<(Status, Option<Side>)> {
162    if !row.status.is_ongoing() {
163        return None;
164    }
165    if row.ply < 2 {
166        let limit = i64::try_from(ctx.cfg.first_move_secs * 1000).unwrap_or(i64::MAX);
167        return (row.elapsed_ms > limit + ctx.cfg.lag_comp_ms).then_some((Status::Aborted, None));
168    }
169    if !row.clock_running() {
170        return None;
171    }
172    let turn = row.turn();
173    let left = match turn {
174        Side::White => row.white_ms,
175        Side::Black => row.black_ms,
176    }
177    .unwrap_or(0);
178    if row.elapsed_ms - ctx.cfg.lag_comp_ms < left {
179        return None;
180    }
181    let winner = turn.opposite();
182    // Lichess's rule: a flag against a side that can't mate is a draw.
183    Some(if game.cannot_win(winner) { (Status::Outoftime, None) } else { (Status::Outoftime, Some(winner)) })
184}
185
186pub async fn resign(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
187    let mut c = ctx.db.conn().await?;
188    let tx = c.transaction().await?;
189    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
190    if row.ply < 2 && !row.has_rule("noAbort") {
191        finish(ctx, &tx, &row, Status::Aborted, None).await?;
192    } else {
193        finish(ctx, &tx, &row, Status::Resign, Some(side.opposite())).await?;
194    }
195    tx.commit().await?;
196    Ok(())
197}
198
199pub async fn abort(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
200    let mut c = ctx.db.conn().await?;
201    let tx = c.transaction().await?;
202    let (row, _) = lock_playing(ctx, &tx, id, player, api).await?;
203    if row.ply >= 2 || row.has_rule("noAbort") {
204        return Err(ApiError::bad("This game can no longer be aborted"));
205    }
206    finish(ctx, &tx, &row, Status::Aborted, None).await?;
207    tx.commit().await?;
208    Ok(())
209}
210
211pub async fn draw(ctx: &Ctx, id: &str, player: i64, api: Api, accept: bool) -> ApiResult<()> {
212    let mut c = ctx.db.conn().await?;
213    let tx = c.transaction().await?;
214    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
215    let opp_offered = row.draw_offer == Some(side.opposite());
216    if accept && opp_offered {
217        finish(ctx, &tx, &row, Status::Draw, None).await?;
218    } else {
219        let offer = if accept {
220            if row.has_rule("noEarlyDraw") && row.ply < 60 {
221                return Err(ApiError::bad("Draw offers aren't allowed this early in the game"));
222            }
223            Some(side)
224        } else {
225            None
226        };
227        tx.execute(
228            &ctx.db.sql("UPDATE chess.games SET draw_offer = $2 WHERE id = $1"),
229            &[&row.id, &side_str(offer)],
230        )
231        .await?;
232        notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
233    }
234    tx.commit().await?;
235    Ok(())
236}
237
238pub async fn takeback(ctx: &Ctx, id: &str, player: i64, api: Api, accept: bool) -> ApiResult<()> {
239    let mut c = ctx.db.conn().await?;
240    let tx = c.transaction().await?;
241    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
242    let opp = side.opposite();
243    if !accept {
244        if row.takeback_offer.is_some() {
245            tx.execute(&ctx.db.sql("UPDATE chess.games SET takeback_offer = NULL WHERE id = $1"), &[&row.id])
246                .await?;
247            notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
248        }
249    } else if row.takeback_offer == Some(opp) {
250        // The proposer's last move goes; if it's their turn, so does ours.
251        let n = if row.turn() == opp { 2 } else { 1 };
252        if (row.ply as usize) < n {
253            return Err(ApiError::bad("Nothing to take back"));
254        }
255        let mut game = replay(&row)?;
256        game.undo(n);
257        let keep = game.ply();
258        let mut move_ms = row.move_ms.clone();
259        let mut clock_ms = row.clock_ms.clone();
260        move_ms.truncate(keep);
261        clock_ms.truncate(keep);
262        let (white_ms, black_ms) = restored_clocks(&row, game.first_to_move(), &clock_ms);
263        tx.execute(
264            &ctx.db.sql(
265                "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, move_ms = $5, clock_ms = $6,
266                        white_ms = $7, black_ms = $8,
267                        takeback_offer = NULL, draw_offer = NULL, turn_started_at = clock_timestamp()
268                  WHERE id = $1",
269            ),
270            &[
271                &row.id,
272                &game.uci_moves().join(" "),
273                &(keep as i32),
274                &game.fen(),
275                &move_ms,
276                &clock_ms,
277                &white_ms,
278                &black_ms,
279            ],
280        )
281        .await?;
282        notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
283    } else if row.takeback_offer != Some(side) {
284        if !row.has_moved(side) {
285            return Err(ApiError::bad("You haven't moved yet"));
286        }
287        tx.execute(
288            &ctx.db.sql("UPDATE chess.games SET takeback_offer = $2 WHERE id = $1"),
289            &[&row.id, &side.name()],
290        )
291        .await?;
292        notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
293    }
294    tx.commit().await?;
295    Ok(())
296}
297
298/// Each side's clock after a takeback: what it read after that side's last
299/// remaining move, or the starting time if it has none left.
300fn restored_clocks(row: &GameRow, first: Side, clock_ms: &[i64]) -> (Option<i64>, Option<i64>) {
301    let Some(init) = row.clock_initial_ms else { return (row.white_ms, row.black_ms) };
302    let last = |side: Side| {
303        clock_ms
304            .iter()
305            .enumerate()
306            .rev()
307            .find(|(i, _)| Side::at_ply(first, *i) == side)
308            .map_or(init, |(_, ms)| *ms)
309    };
310    (Some(last(Side::White)), Some(last(Side::Black)))
311}
312
313/// Seconds since `player` last had a stream open on this game (since the
314/// game started, if never).
315pub async fn absent_secs(ctx: &Ctx, c: &impl GenericClient, row: &GameRow, player: i64) -> Result<i64> {
316    let r = c
317        .query_one(
318            &ctx.db.sql(
319                "SELECT extract(epoch from (clock_timestamp() - coalesce(
320                        (SELECT seen_at FROM chess.presence WHERE game_id = $1 AND player_id = $2),
321                        g.created_at)))::bigint
322                   FROM chess.games g WHERE g.id = $1",
323            ),
324            &[&row.id, &player],
325        )
326        .await?;
327    Ok(r.get(0))
328}
329
330/// Seconds until the opponent may claim the win (None: `player` isn't gone).
331pub fn claim_in(ctx: &Ctx, absent: i64) -> Option<i64> {
332    let gone = i64::try_from(ctx.cfg.gone_after_secs).unwrap_or(i64::MAX);
333    let claim = i64::try_from(ctx.cfg.claim_win_secs).unwrap_or(i64::MAX);
334    (absent >= gone).then(|| (gone + claim - absent).max(0))
335}
336
337async fn claim(ctx: &Ctx, id: &str, player: i64, api: Api, victory: bool) -> ApiResult<()> {
338    let mut c = ctx.db.conn().await?;
339    let tx = c.transaction().await?;
340    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
341    if row.has_rule("noClaimWin") {
342        return Err(ApiError::bad("Claiming isn't allowed in this game"));
343    }
344    let opp = row.player(side.opposite()).player_id;
345    let absent = absent_secs(ctx, &tx, &row, opp).await?;
346    if claim_in(ctx, absent) != Some(0) {
347        return Err(ApiError::bad("Your opponent is still here"));
348    }
349    if victory {
350        finish(ctx, &tx, &row, Status::Timeout, Some(side)).await?;
351    } else {
352        finish(ctx, &tx, &row, Status::Draw, None).await?;
353    }
354    tx.commit().await?;
355    Ok(())
356}
357
358pub async fn claim_victory(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
359    claim(ctx, id, player, api, true).await
360}
361
362pub async fn claim_draw(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
363    claim(ctx, id, player, api, false).await
364}
365
366/// Record that `player` has a stream open on a game.
367pub async fn touch(ctx: &Ctx, game: &str, player: i64) -> Result<()> {
368    let c = ctx.db.conn().await?;
369    c.execute(
370        &ctx.db.sql(
371            "INSERT INTO chess.presence (game_id, player_id, seen_at) VALUES ($1, $2, clock_timestamp())
372             ON CONFLICT (game_id, player_id) DO UPDATE SET seen_at = EXCLUDED.seen_at",
373        ),
374        &[&game, &player],
375    )
376    .await?;
377    Ok(())
378}
379
380/// End a game: status and winner, ratings for a rated game, fair-play
381/// queueing, and the gameFinish events. `row` must be locked.
382pub async fn finish(
383    ctx: &Ctx,
384    tx: &Transaction<'_>,
385    row: &GameRow,
386    status: Status,
387    winner: Option<Side>,
388) -> Result<()> {
389    let humans = !row.white.bot || !row.black.bot;
390    let analysis =
391        if ctx.cfg.fairplay && humans && !status.is_void() && row.ply >= 20 { "pending" } else { "skipped" };
392    let (wd, bd) =
393        if row.rated && !status.is_void() { rate(ctx, tx, row, winner).await? } else { (None, None) };
394    // The clocks stop: the side to move is charged its running time, and a
395    // flag leaves it with nothing.
396    let (mut wms, mut bms) = (row.white_ms, row.black_ms);
397    if row.clock_running() {
398        let left = if status == Status::Outoftime { 0 } else { row.live_ms(row.turn()) };
399        match row.turn() {
400            Side::White => wms = Some(left),
401            Side::Black => bms = Some(left),
402        }
403    }
404    tx.execute(
405        &ctx.db.sql(
406            "UPDATE chess.games SET status = $2, winner = $3, finished_at = clock_timestamp(), draw_offer = NULL,
407                    takeback_offer = NULL, analysis = $4, white_rating_diff = $5, black_rating_diff = $6,
408                    white_ms = $7, black_ms = $8
409              WHERE id = $1",
410        ),
411        &[&row.id, &status.id(), &side_str(winner), &analysis, &wd, &bd, &wms, &bms],
412    )
413    .await?;
414    ctx.metrics.games_finished.with_label_values(&[status.name()]).inc();
415    let done = model::game(&ctx.db, tx, &row.id).await?.unwrap_or_else(|| row.clone());
416    notify(tx, &ctx.db, &Note::Game(row.id.clone())).await?;
417    for p in [row.white.player_id, row.black.player_id] {
418        if let Some(e) = done.event(p, chess_proto::GameEvent::FINISH) {
419            notify(tx, &ctx.db, &Note::User { player: p, event: e }).await?;
420        }
421    }
422    debug!(game = row.id, status = status.name(), "game over");
423    Ok(())
424}
425
426/// Glicko-2 update for both players (rows locked in player-id order so two
427/// games finishing at once can't deadlock).
428async fn rate(
429    ctx: &Ctx,
430    tx: &Transaction<'_>,
431    row: &GameRow,
432    winner: Option<Side>,
433) -> Result<(Option<i32>, Option<i32>)> {
434    let perf = row.perf_key();
435    let mut ids = [row.white.player_id, row.black.player_id];
436    ids.sort_unstable();
437    for id in ids {
438        tx.execute(
439            &ctx.db.sql(
440                "INSERT INTO chess.ratings (player_id, perf, rating, deviation, volatility)
441                 VALUES ($1, $2, 1500, 500, 0.09) ON CONFLICT DO NOTHING",
442            ),
443            &[&id, &perf],
444        )
445        .await?;
446        tx.execute(
447            &ctx.db.sql("SELECT 1 FROM chess.ratings WHERE player_id = $1 AND perf = $2 FOR UPDATE"),
448            &[&id, &perf],
449        )
450        .await?;
451    }
452    let (w, _, _) = model::rating(&ctx.db, tx, row.white.player_id, perf).await?;
453    let (b, _, _) = model::rating(&ctx.db, tx, row.black.player_id, perf).await?;
454    let (sw, sb) = match winner {
455        Some(Side::White) => (GameScore::Win, GameScore::Loss),
456        Some(Side::Black) => (GameScore::Loss, GameScore::Win),
457        None => (GameScore::Draw, GameScore::Draw),
458    };
459    let nw = w.after_game(&b, sw);
460    let nb = b.after_game(&w, sb);
461    let mut diffs = [0; 2];
462    for (i, (id, old, new)) in
463        [(row.white.player_id, w, nw), (row.black.player_id, b, nb)].into_iter().enumerate()
464    {
465        let diff = new.int() - old.int();
466        diffs[i] = diff;
467        tx.execute(
468            &ctx.db.sql(
469                "UPDATE chess.ratings SET rating = $3, deviation = $4, volatility = $5, games = games + 1,
470                        prog = $6, updated_at = now()
471                  WHERE player_id = $1 AND perf = $2",
472            ),
473            &[&id, &perf, &new.rating, &new.deviation, &new.volatility, &diff],
474        )
475        .await?;
476    }
477    Ok((Some(diffs[0]), Some(diffs[1])))
478}
479
480/// Start the game for an accepted challenge (same id as the challenge).
481pub async fn start_game(ctx: &Ctx, tx: &Transaction<'_>, ch: &ChallengeRow) -> Result<GameRow> {
482    let (white, black) = match ch.final_color {
483        Side::White => (&ch.challenger, &ch.dest),
484        Side::Black => (&ch.dest, &ch.challenger),
485    };
486    let speed = ch.speed();
487    let (wr, _, _) = model::rating(&ctx.db, tx, white.player_id, speed.key()).await?;
488    let (br, _, _) = model::rating(&ctx.db, tx, black.player_id, speed.key()).await?;
489    let game = Game::new(ch.initial_fen.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
490    let init = ch.clock_limit.map(|l| i64::from(l) * 1000);
491    let inc = ch.clock_increment.map(|i| i64::from(i) * 1000);
492    tx.execute(
493        &ctx.db.sql(
494            "INSERT INTO chess.games (id, white_id, black_id, variant, initial_fen, rated, source, speed, rules,
495                    clock_initial_ms, clock_increment_ms, status, fen, white_ms, black_ms, white_rating, black_rating,
496                    white_provisional, black_provisional, created_at, turn_started_at)
497             VALUES ($1, $2, $3, $4, $5, $6, 'friend', $7, $8, $9, $10, 20, $11, $9, $9, $12, $13, $14, $15,
498                     clock_timestamp(), clock_timestamp())",
499        ),
500        &[
501            &ch.id,
502            &white.player_id,
503            &black.player_id,
504            &ch.variant,
505            &game.initial_fen(),
506            &ch.rated,
507            &speed.key(),
508            &ch.rules,
509            &init,
510            &inc,
511            &game.fen(),
512            &wr.int(),
513            &br.int(),
514            &wr.provisional(),
515            &br.provisional(),
516        ],
517    )
518    .await?;
519    let row = model::game(&ctx.db, tx, &ch.id).await?.ok_or_else(|| anyhow::anyhow!("new game vanished"))?;
520    for p in [white.player_id, black.player_id] {
521        if let Some(e) = row.event(p, chess_proto::GameEvent::START) {
522            notify(tx, &ctx.db, &Note::User { player: p, event: e }).await?;
523        }
524    }
525    notify(tx, &ctx.db, &Note::Game(row.id.clone())).await?;
526    ctx.metrics.games_started.inc();
527    Ok(row)
528}
529
530/// One pass of the sweeper: lapsed challenges, flags and first moves that
531/// never came. Safe on every replica at once (rows are locked with SKIP
532/// LOCKED and conditions rechecked under the lock).
533pub async fn sweep(ctx: &Ctx) -> Result<usize> {
534    let mut done = 0;
535    done += crate::challenges::expire(ctx).await?;
536    let c = ctx.db.conn().await?;
537    let ids: Vec<String> = c
538        .query(
539            &ctx.db.sql(
540                "SELECT id FROM chess.games
541                  WHERE status = 20 AND (
542                        (ply < 2 AND clock_timestamp() > turn_started_at + make_interval(secs => ($1::float8 + $2::float8) / 1000.0))
543                     OR (ply >= 2 AND clock_initial_ms IS NOT NULL
544                         AND clock_timestamp() > turn_started_at
545                             + make_interval(secs => ((CASE WHEN fen LIKE '% w %' THEN white_ms ELSE black_ms END) + $2::float8) / 1000.0)))
546                  LIMIT 100",
547            ),
548            &[&(ctx.cfg.first_move_secs as f64 * 1000.0), &(ctx.cfg.lag_comp_ms as f64)],
549        )
550        .await?
551        .iter()
552        .map(|r| r.get(0))
553        .collect();
554    drop(c);
555    for id in ids {
556        let mut c = ctx.db.conn().await?;
557        let tx = c.transaction().await?;
558        let row = tx
559            .query_opt(
560                &ctx.db.sql(&format!("{} WHERE g.id = $1 FOR UPDATE OF g SKIP LOCKED", model::GAME_SELECT)),
561                &[&id],
562            )
563            .await?
564            .map(|r| GameRow::from_row(&r));
565        let Some(row) = row else { continue };
566        let game = match replay(&row) {
567            Ok(g) => g,
568            Err(e) => {
569                warn!("{e:#}");
570                continue;
571            }
572        };
573        if let Some((status, winner)) = timed_out(ctx, &row, &game) {
574            finish(ctx, &tx, &row, status, winner).await?;
575            done += 1;
576        }
577        tx.commit().await?;
578    }
579    Ok(done)
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn api_kind_checks() {
588        let human = Account {
589            player_id: 1,
590            username: "a".into(),
591            bot: false,
592            marked: false,
593            created_ms: 0,
594            seen_ms: None,
595        };
596        let bot = Account { bot: true, ..human.clone() };
597        assert!(Api::Board.check(&human).is_ok());
598        assert!(Api::Bot.check(&bot).is_ok());
599        assert!(Api::Board.check(&bot).is_err());
600        assert!(Api::Bot.check(&human).is_err());
601    }
602}