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    // Correspondence: the other player may be anywhere; tell their phone.
156    if row.days_per_turn.is_some() && end.is_none() {
157        let me = &row.player(side).username;
158        let them = row.player(side.opposite()).player_id;
159        crate::push::notify(
160            ctx,
161            them,
162            "Your move".into(),
163            format!("{me} played {}.", mv.san),
164            Some(row.id.clone()),
165            Some(row.id.clone()),
166        );
167    }
168    debug!(game = id, uci = mv.uci, "move");
169    Ok(())
170}
171
172/// The game is over on time before anything else can happen: a flag (the
173/// side to move's clock ran out) or a first move that never came.
174fn timed_out(ctx: &Ctx, row: &GameRow, game: &Game) -> Option<(Status, Option<Side>)> {
175    // A game on a real board keeps its own time (a real clock, or none):
176    // whoever records it ends it.
177    if !row.status.is_ongoing() || row.source == "otb" {
178        return None;
179    }
180    if let Some(left) = row.days_left_ms() {
181        // Correspondence: the days run from the first move too.
182        if left > 0 {
183            return None;
184        }
185        if row.ply < 2 {
186            return Some((Status::Aborted, None));
187        }
188        let winner = row.turn().opposite();
189        return Some(if game.cannot_win(winner) {
190            (Status::Outoftime, None)
191        } else {
192            (Status::Outoftime, Some(winner))
193        });
194    }
195    if row.ply < 2 {
196        let limit = i64::try_from(ctx.cfg.first_move_secs * 1000).unwrap_or(i64::MAX);
197        return (row.elapsed_ms > limit + ctx.cfg.lag_comp_ms).then_some((Status::Aborted, None));
198    }
199    if !row.clock_running() {
200        return None;
201    }
202    let turn = row.turn();
203    let left = match turn {
204        Side::White => row.white_ms,
205        Side::Black => row.black_ms,
206    }
207    .unwrap_or(0);
208    if row.elapsed_ms - ctx.cfg.lag_comp_ms < left {
209        return None;
210    }
211    let winner = turn.opposite();
212    // Lichess's rule: a flag against a side that can't mate is a draw.
213    Some(if game.cannot_win(winner) { (Status::Outoftime, None) } else { (Status::Outoftime, Some(winner)) })
214}
215
216pub async fn resign(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
217    let mut c = ctx.db.conn().await?;
218    let tx = c.transaction().await?;
219    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
220    if row.ply < 2 && !row.has_rule("noAbort") {
221        finish(ctx, &tx, &row, Status::Aborted, None).await?;
222    } else {
223        finish(ctx, &tx, &row, Status::Resign, Some(side.opposite())).await?;
224    }
225    tx.commit().await?;
226    Ok(())
227}
228
229pub async fn abort(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
230    let mut c = ctx.db.conn().await?;
231    let tx = c.transaction().await?;
232    let (row, _) = lock_playing(ctx, &tx, id, player, api).await?;
233    if row.ply >= 2 || row.has_rule("noAbort") {
234        return Err(ApiError::bad("This game can no longer be aborted"));
235    }
236    finish(ctx, &tx, &row, Status::Aborted, None).await?;
237    tx.commit().await?;
238    Ok(())
239}
240
241pub async fn draw(ctx: &Ctx, id: &str, player: i64, api: Api, accept: bool) -> ApiResult<()> {
242    let mut c = ctx.db.conn().await?;
243    let tx = c.transaction().await?;
244    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
245    let opp_offered = row.draw_offer == Some(side.opposite());
246    if accept && opp_offered {
247        finish(ctx, &tx, &row, Status::Draw, None).await?;
248    } else {
249        let offer = if accept {
250            if row.has_rule("noEarlyDraw") && row.ply < 60 {
251                return Err(ApiError::bad("Draw offers aren't allowed this early in the game"));
252            }
253            Some(side)
254        } else {
255            None
256        };
257        tx.execute(
258            &ctx.db.sql("UPDATE chess.games SET draw_offer = $2 WHERE id = $1"),
259            &[&row.id, &side_str(offer)],
260        )
261        .await?;
262        notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
263    }
264    tx.commit().await?;
265    Ok(())
266}
267
268pub async fn takeback(ctx: &Ctx, id: &str, player: i64, api: Api, accept: bool) -> ApiResult<()> {
269    let mut c = ctx.db.conn().await?;
270    let tx = c.transaction().await?;
271    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
272    let opp = side.opposite();
273    if !accept {
274        if row.takeback_offer.is_some() {
275            tx.execute(&ctx.db.sql("UPDATE chess.games SET takeback_offer = NULL WHERE id = $1"), &[&row.id])
276                .await?;
277            notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
278        }
279    } else if row.takeback_offer == Some(opp) {
280        // The proposer's last move goes; if it's their turn, so does ours.
281        let n = if row.turn() == opp { 2 } else { 1 };
282        if (row.ply as usize) < n {
283            return Err(ApiError::bad("Nothing to take back"));
284        }
285        let mut game = replay(&row)?;
286        game.undo(n);
287        let keep = game.ply();
288        let mut move_ms = row.move_ms.clone();
289        let mut clock_ms = row.clock_ms.clone();
290        move_ms.truncate(keep);
291        clock_ms.truncate(keep);
292        let (white_ms, black_ms) = restored_clocks(&row, game.first_to_move(), &clock_ms);
293        tx.execute(
294            &ctx.db.sql(
295                "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, move_ms = $5, clock_ms = $6,
296                        white_ms = $7, black_ms = $8,
297                        takeback_offer = NULL, draw_offer = NULL, turn_started_at = clock_timestamp()
298                  WHERE id = $1",
299            ),
300            &[
301                &row.id,
302                &game.uci_moves().join(" "),
303                &(keep as i32),
304                &game.fen(),
305                &move_ms,
306                &clock_ms,
307                &white_ms,
308                &black_ms,
309            ],
310        )
311        .await?;
312        notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
313    } else if row.takeback_offer != Some(side) {
314        if !row.has_moved(side) {
315            return Err(ApiError::bad("You haven't moved yet"));
316        }
317        tx.execute(
318            &ctx.db.sql("UPDATE chess.games SET takeback_offer = $2 WHERE id = $1"),
319            &[&row.id, &side.name()],
320        )
321        .await?;
322        notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
323    }
324    tx.commit().await?;
325    Ok(())
326}
327
328/// Each side's clock after a takeback: what it read after that side's last
329/// remaining move, or the starting time if it has none left.
330fn restored_clocks(row: &GameRow, first: Side, clock_ms: &[i64]) -> (Option<i64>, Option<i64>) {
331    let Some(init) = row.clock_initial_ms else { return (row.white_ms, row.black_ms) };
332    let last = |side: Side| {
333        clock_ms
334            .iter()
335            .enumerate()
336            .rev()
337            .find(|(i, _)| Side::at_ply(first, *i) == side)
338            .map_or(init, |(_, ms)| *ms)
339    };
340    (Some(last(Side::White)), Some(last(Side::Black)))
341}
342
343/// Seconds since `player` last had a stream open on this game (since the
344/// game started, if never).
345pub async fn absent_secs(ctx: &Ctx, c: &impl GenericClient, row: &GameRow, player: i64) -> Result<i64> {
346    let r = c
347        .query_one(
348            &ctx.db.sql(
349                "SELECT extract(epoch from (clock_timestamp() - coalesce(
350                        (SELECT seen_at FROM chess.presence WHERE game_id = $1 AND player_id = $2),
351                        g.created_at)))::bigint
352                   FROM chess.games g WHERE g.id = $1",
353            ),
354            &[&row.id, &player],
355        )
356        .await?;
357    Ok(r.get(0))
358}
359
360/// Seconds until the opponent may claim the win (None: `player` isn't gone).
361pub fn claim_in(ctx: &Ctx, absent: i64) -> Option<i64> {
362    let gone = i64::try_from(ctx.cfg.gone_after_secs).unwrap_or(i64::MAX);
363    let claim = i64::try_from(ctx.cfg.claim_win_secs).unwrap_or(i64::MAX);
364    (absent >= gone).then(|| (gone + claim - absent).max(0))
365}
366
367async fn claim(ctx: &Ctx, id: &str, player: i64, api: Api, victory: bool) -> ApiResult<()> {
368    let mut c = ctx.db.conn().await?;
369    let tx = c.transaction().await?;
370    let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
371    if row.has_rule("noClaimWin") {
372        return Err(ApiError::bad("Claiming isn't allowed in this game"));
373    }
374    let opp = row.player(side.opposite()).player_id;
375    let absent = absent_secs(ctx, &tx, &row, opp).await?;
376    if claim_in(ctx, absent) != Some(0) {
377        return Err(ApiError::bad("Your opponent is still here"));
378    }
379    if victory {
380        finish(ctx, &tx, &row, Status::Timeout, Some(side)).await?;
381    } else {
382        finish(ctx, &tx, &row, Status::Draw, None).await?;
383    }
384    tx.commit().await?;
385    Ok(())
386}
387
388pub async fn claim_victory(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
389    claim(ctx, id, player, api, true).await
390}
391
392pub async fn claim_draw(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
393    claim(ctx, id, player, api, false).await
394}
395
396/// Record that `player` has a stream open on a game.
397pub async fn touch(ctx: &Ctx, game: &str, player: i64) -> Result<()> {
398    let c = ctx.db.conn().await?;
399    c.execute(
400        &ctx.db.sql(
401            "INSERT INTO chess.presence (game_id, player_id, seen_at) VALUES ($1, $2, clock_timestamp())
402             ON CONFLICT (game_id, player_id) DO UPDATE SET seen_at = EXCLUDED.seen_at",
403        ),
404        &[&game, &player],
405    )
406    .await?;
407    Ok(())
408}
409
410/// End a game: status and winner, ratings for a rated game, fair-play
411/// queueing, and the gameFinish events. `row` must be locked.
412pub async fn finish(
413    ctx: &Ctx,
414    tx: &Transaction<'_>,
415    row: &GameRow,
416    status: Status,
417    winner: Option<Side>,
418) -> Result<()> {
419    let humans = !row.white.bot || !row.black.bot;
420    let analysis =
421        if ctx.cfg.fairplay && humans && !status.is_void() && row.ply >= 20 { "pending" } else { "skipped" };
422    let (wd, bd) =
423        if row.rated && !status.is_void() { rate(ctx, tx, row, winner).await? } else { (None, None) };
424    // The clocks stop: the side to move is charged its running time, and a
425    // flag leaves it with nothing.
426    let (mut wms, mut bms) = (row.white_ms, row.black_ms);
427    if row.clock_running() {
428        let left = if status == Status::Outoftime { 0 } else { row.live_ms(row.turn()) };
429        match row.turn() {
430            Side::White => wms = Some(left),
431            Side::Black => bms = Some(left),
432        }
433    }
434    tx.execute(
435        &ctx.db.sql(
436            "UPDATE chess.games SET status = $2, winner = $3, finished_at = clock_timestamp(), feed_at = clock_timestamp(), draw_offer = NULL,
437                    takeback_offer = NULL, analysis = $4, white_rating_diff = $5, black_rating_diff = $6,
438                    white_ms = $7, black_ms = $8
439              WHERE id = $1",
440        ),
441        &[&row.id, &status.id(), &side_str(winner), &analysis, &wd, &bd, &wms, &bms],
442    )
443    .await?;
444    ctx.metrics.games_finished.with_label_values(&[status.name()]).inc();
445    let done = model::game(&ctx.db, tx, &row.id).await?.unwrap_or_else(|| row.clone());
446    notify(tx, &ctx.db, &Note::Game(row.id.clone())).await?;
447    for p in [row.white.player_id, row.black.player_id] {
448        if let Some(e) = done.event(p, chess_proto::GameEvent::FINISH) {
449            notify(tx, &ctx.db, &Note::User { player: p, event: e }).await?;
450        }
451    }
452    debug!(game = row.id, status = status.name(), "game over");
453    Ok(())
454}
455
456/// Glicko-2 update for both players (rows locked in player-id order so two
457/// games finishing at once can't deadlock).
458pub(crate) async fn rate(
459    ctx: &Ctx,
460    tx: &Transaction<'_>,
461    row: &GameRow,
462    winner: Option<Side>,
463) -> Result<(Option<i32>, Option<i32>)> {
464    let perf = row.perf_key();
465    let mut ids = [row.white.player_id, row.black.player_id];
466    ids.sort_unstable();
467    for id in ids {
468        tx.execute(
469            &ctx.db.sql(
470                "INSERT INTO chess.ratings (player_id, perf, rating, deviation, volatility)
471                 VALUES ($1, $2, 1500, 500, 0.09) ON CONFLICT DO NOTHING",
472            ),
473            &[&id, &perf],
474        )
475        .await?;
476        tx.execute(
477            &ctx.db.sql("SELECT 1 FROM chess.ratings WHERE player_id = $1 AND perf = $2 FOR UPDATE"),
478            &[&id, &perf],
479        )
480        .await?;
481    }
482    let (w, _, _) = model::rating(&ctx.db, tx, row.white.player_id, perf).await?;
483    let (b, _, _) = model::rating(&ctx.db, tx, row.black.player_id, perf).await?;
484    let (sw, sb) = match winner {
485        Some(Side::White) => (GameScore::Win, GameScore::Loss),
486        Some(Side::Black) => (GameScore::Loss, GameScore::Win),
487        None => (GameScore::Draw, GameScore::Draw),
488    };
489    let nw = w.after_game(&b, sw);
490    let nb = b.after_game(&w, sb);
491    let mut diffs = [0; 2];
492    for (i, (account, old, new)) in [(&row.white, w, nw), (&row.black, b, nb)].into_iter().enumerate() {
493        // The computer keeps its level's fixed rating.
494        if crate::ai::level_of(&account.username).is_some() {
495            continue;
496        }
497        let id = account.player_id;
498        let diff = new.int() - old.int();
499        diffs[i] = diff;
500        tx.execute(
501            &ctx.db.sql(
502                "UPDATE chess.ratings SET rating = $3, deviation = $4, volatility = $5, games = games + 1,
503                        prog = $6, updated_at = now()
504                  WHERE player_id = $1 AND perf = $2",
505            ),
506            &[&id, &perf, &new.rating, &new.deviation, &new.volatility, &diff],
507        )
508        .await?;
509    }
510    Ok((Some(diffs[0]), Some(diffs[1])))
511}
512
513/// Start the game for an accepted challenge (same id as the challenge).
514pub async fn start_game(ctx: &Ctx, tx: &Transaction<'_>, ch: &ChallengeRow) -> Result<GameRow> {
515    let (white, black) = match ch.final_color {
516        Side::White => (&ch.challenger, &ch.dest),
517        Side::Black => (&ch.dest, &ch.challenger),
518    };
519    let speed = ch.speed();
520    let (wr, _, _) = model::rating(&ctx.db, tx, white.player_id, speed.key()).await?;
521    let (br, _, _) = model::rating(&ctx.db, tx, black.player_id, speed.key()).await?;
522    let game = Game::new(ch.initial_fen.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
523    let init = ch.clock_limit.map(|l| i64::from(l) * 1000);
524    let inc = ch.clock_increment.map(|i| i64::from(i) * 1000);
525    tx.execute(
526        &ctx.db.sql(
527            "INSERT INTO chess.games (id, white_id, black_id, variant, initial_fen, rated, source, speed, rules,
528                    clock_initial_ms, clock_increment_ms, status, fen, white_ms, black_ms, white_rating, black_rating,
529                    white_provisional, black_provisional, created_at, turn_started_at, days_per_turn)
530             VALUES ($1, $2, $3, $4, $5, $6, 'friend', $7, $8, $9, $10, 20, $11, $9, $9, $12, $13, $14, $15,
531                     clock_timestamp(), clock_timestamp(), $16)",
532        ),
533        &[
534            &ch.id,
535            &white.player_id,
536            &black.player_id,
537            &ch.variant,
538            &game.initial_fen(),
539            &ch.rated,
540            &speed.key(),
541            &ch.rules,
542            &init,
543            &inc,
544            &game.fen(),
545            &wr.int(),
546            &br.int(),
547            &wr.provisional(),
548            &br.provisional(),
549            &ch.days,
550        ],
551    )
552    .await?;
553    let row = model::game(&ctx.db, tx, &ch.id).await?.ok_or_else(|| anyhow::anyhow!("new game vanished"))?;
554    for p in [white.player_id, black.player_id] {
555        if let Some(e) = row.event(p, chess_proto::GameEvent::START) {
556            notify(tx, &ctx.db, &Note::User { player: p, event: e }).await?;
557        }
558    }
559    notify(tx, &ctx.db, &Note::Game(row.id.clone())).await?;
560    ctx.metrics.games_started.inc();
561    Ok(row)
562}
563
564/// One pass of the sweeper: lapsed challenges, flags and first moves that
565/// never came. Safe on every replica at once (rows are locked with SKIP
566/// LOCKED and conditions rechecked under the lock).
567pub async fn sweep(ctx: &Ctx) -> Result<usize> {
568    let mut done = 0;
569    done += crate::challenges::expire(ctx).await?;
570    let c = ctx.db.conn().await?;
571    let ids: Vec<String> = c
572        .query(
573            &ctx.db.sql(
574                "SELECT id FROM chess.games
575                  WHERE status = 20 AND source <> 'otb' AND (
576                        (days_per_turn IS NOT NULL AND clock_timestamp() > turn_started_at + make_interval(days => days_per_turn))
577                     OR (days_per_turn IS NULL AND ply < 2
578                         AND clock_timestamp() > turn_started_at + make_interval(secs => ($1::float8 + $2::float8) / 1000.0))
579                     OR (ply >= 2 AND clock_initial_ms IS NOT NULL
580                         AND clock_timestamp() > turn_started_at
581                             + make_interval(secs => ((CASE WHEN fen LIKE '% w %' THEN white_ms ELSE black_ms END) + $2::float8) / 1000.0)))
582                  LIMIT 100",
583            ),
584            &[&(ctx.cfg.first_move_secs as f64 * 1000.0), &(ctx.cfg.lag_comp_ms as f64)],
585        )
586        .await?
587        .iter()
588        .map(|r| r.get(0))
589        .collect();
590    drop(c);
591    for id in ids {
592        let mut c = ctx.db.conn().await?;
593        let tx = c.transaction().await?;
594        let row = tx
595            .query_opt(
596                &ctx.db.sql(&format!("{} WHERE g.id = $1 FOR UPDATE OF g SKIP LOCKED", model::GAME_SELECT)),
597                &[&id],
598            )
599            .await?
600            .map(|r| GameRow::from_row(&r));
601        let Some(row) = row else { continue };
602        let game = match replay(&row) {
603            Ok(g) => g,
604            Err(e) => {
605                warn!("{e:#}");
606                continue;
607            }
608        };
609        if let Some((status, winner)) = timed_out(ctx, &row, &game) {
610            finish(ctx, &tx, &row, status, winner).await?;
611            done += 1;
612        }
613        tx.commit().await?;
614    }
615    Ok(done)
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn api_kind_checks() {
624        let human = Account {
625            player_id: 1,
626            username: "a".into(),
627            bot: false,
628            marked: false,
629            created_ms: 0,
630            seen_ms: None,
631        };
632        let bot = Account { bot: true, ..human.clone() };
633        assert!(Api::Board.check(&human).is_ok());
634        assert!(Api::Bot.check(&bot).is_ok());
635        assert!(Api::Board.check(&bot).is_err());
636        assert!(Api::Bot.check(&human).is_err());
637    }
638}