Skip to main content

open_chess_server/http/
lab.rs

1//! open-chess additions to the Lichess API, used by the chess
2//! training app:
3//!
4//! - `POST /api/board/game/{id}/assist`: the app declares that its engine
5//!   help was switched on or off during one of our games (ground truth for
6//!   the fair-play model);
7//! - `GET /api/esl/position?fen=`: the position dictionary — the deepest
8//!   stored evaluation of a position, with win/draw/loss odds, and the
9//!   deepest analysis a training app sent in (a hint);
10//! - `POST /api/esl/position`: send in the app's own analysis of a position;
11//! - `GET /api/esl/position/moves?fen=`: every legal move with its value,
12//!   where the dictionary knows it;
13//! - `POST /api/esl/recognise`: read a board from a photo;
14//! - `POST /api/esl/photos/{id}/confirm`: say what a photo kept for review
15//!   showed.
16//! - `GET /api/esl/tablebase?fen=`: the exact result of a position with up to
17//!   seven pieces, from the tablebase server, kept once fetched.
18
19use axum::Json;
20use axum::extract::{Path, Query, State};
21use axum::response::{IntoResponse, Response};
22use bytes::Bytes;
23use chess_core::Game;
24use serde::Deserialize;
25use serde_json::json;
26
27use crate::app::{Ctx, Who};
28use crate::error::{ApiError, ApiResult};
29use crate::http::{form, ok};
30use crate::{model, vision};
31
32/// The dictionary key for a position (see `chess_analysis::dictionary`).
33pub fn position_key(game: &Game) -> String {
34    chess_analysis::dictionary::position_key(game)
35}
36
37pub async fn assist(
38    State(ctx): State<Ctx>,
39    who: Who,
40    Path(id): Path<String>,
41    body: Bytes,
42) -> ApiResult<Response> {
43    let me = who.need(&["board:play"])?.player;
44    let f = form(&body);
45    let on = match f.get("on").map(String::as_str) {
46        Some("true" | "1" | "yes") => true,
47        Some("false" | "0" | "no") => false,
48        _ => return Err(ApiError::bad("say on=true or on=false")),
49    };
50    let c = ctx.db.conn().await?;
51    let row = model::game(&ctx.db, &*c, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
52    if row.white.player_id != me && row.black.player_id != me {
53        return Err(ApiError::forbidden("not your game"));
54    }
55    c.execute(
56        &ctx.db
57            .sql("INSERT INTO chess.assist_marks (game_id, player_id, ply, assist) VALUES ($1, $2, $3, $4)"),
58        &[&id, &me, &row.ply, &on],
59    )
60    .await?;
61    Ok(ok())
62}
63
64#[derive(Deserialize)]
65pub struct PositionQuery {
66    fen: String,
67}
68
69/// White's win/draw/loss odds for a score from the side to move's view.
70fn wdl_for(game: &Game, cp: i32) -> chess_analysis::Wdl {
71    let white_cp = if game.turn() == chess_core::Side::White { cp } else { -cp };
72    let phase = chess_analysis::eval::phase(game.board());
73    chess_analysis::Wdl::from_cp(white_cp, chess_analysis::wdl::Params::for_phase(phase))
74}
75
76fn parse_fen(fen: &str) -> Result<Game, ApiError> {
77    Game::new(Some(fen.trim())).map_err(|e| ApiError::bad(format!("bad FEN: {e:?}")))
78}
79
80/// A stored evaluation is engine help: not while you're playing here.
81async fn not_playing(ctx: &Ctx, c: &impl tokio_postgres::GenericClient, player: i64) -> ApiResult<()> {
82    if !model::ongoing_games(&ctx.db, c, player).await?.is_empty() {
83        return Err(ApiError::forbidden("position lookups are off while you have a game in progress"));
84    }
85    Ok(())
86}
87
88pub async fn position(
89    State(ctx): State<Ctx>,
90    who: Who,
91    Query(q): Query<PositionQuery>,
92) -> ApiResult<Response> {
93    let game = parse_fen(&q.fen)?;
94    let c = ctx.db.conn().await?;
95    not_playing(&ctx, &*c, who.0.player).await?;
96    let key = position_key(&game);
97    let row = c
98        .query_opt(
99            &ctx.db.sql(
100                "SELECT best, score_cp, depth, engine, seen, photos FROM chess.positions WHERE key = $1",
101            ),
102            &[&key],
103        )
104        .await?;
105    let hint = c
106        .query_opt(
107            &ctx.db.sql(
108                "SELECT best, score_cp, depth, engine FROM chess.position_hints WHERE key = $1
109                  ORDER BY depth DESC, at DESC LIMIT 1",
110            ),
111            &[&key],
112        )
113        .await?
114        .map(|h| {
115            let cp: i32 = h.get(1);
116            json!({"best": h.get::<_, Option<String>>(0), "scoreCp": cp, "depth": h.get::<_, i32>(2),
117                   "engine": h.get::<_, String>(3), "wdl": wdl_for(&game, cp)})
118        });
119    let Some(r) = row else {
120        return Ok(Json(json!({ "key": key, "known": false, "hint": hint })).into_response());
121    };
122    let photos: i32 = r.get(5);
123    let Some(score) = r.get::<_, Option<i32>>(1) else {
124        // Confirmed from a photo, waiting for the worker.
125        return Ok(Json(
126            json!({ "key": key, "known": false, "queued": true, "photos": photos, "hint": hint }),
127        )
128        .into_response());
129    };
130    // Stored from the side to move's view; odds are given from White's.
131    Ok(Json(json!({
132        "key": key,
133        "known": true,
134        "best": r.get::<_, Option<String>>(0),
135        "scoreCp": score,
136        "depth": r.get::<_, i32>(2),
137        "engine": r.get::<_, Option<String>>(3),
138        "seen": r.get::<_, i32>(4),
139        "photos": photos,
140        "wdl": wdl_for(&game, score),
141        "hint": hint,
142    }))
143    .into_response())
144}
145
146/// One move's value as the app sends it.
147#[derive(Deserialize, serde::Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct HintMove {
150    uci: String,
151    score_cp: i32,
152}
153
154#[derive(Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct HintBody {
157    fen: String,
158    #[serde(default)]
159    best: Option<String>,
160    /// From the side to move's view.
161    score_cp: i32,
162    depth: i32,
163    #[serde(default)]
164    engine: Option<String>,
165    /// Every move it scored (from the side to move's view).
166    #[serde(default)]
167    moves: Vec<HintMove>,
168}
169
170/// `POST /api/esl/position`: the training app's analysis of a position, a
171/// hint for others looking it up. One per player and position (the deepest
172/// kept); never mixed with the worker's evaluations. Refused while you're
173/// playing a game here.
174pub async fn position_hint(State(ctx): State<Ctx>, who: Who, Json(b): Json<HintBody>) -> ApiResult<Response> {
175    let me = who.0.player;
176    let game = parse_fen(&b.fen)?;
177    if game.outcome().is_some() {
178        return Err(ApiError::bad("the game is over in that position"));
179    }
180    if !(1..=99).contains(&b.depth) || b.score_cp.abs() > 32_000 || b.moves.len() > 256 {
181        return Err(ApiError::bad("depth is 1–99, scores within ±32000, at most 256 moves"));
182    }
183    let legal = game.legal_moves();
184    if b.best.as_ref().is_some_and(|m| !legal.contains(m)) || b.moves.iter().any(|m| !legal.contains(&m.uci))
185    {
186        return Err(ApiError::bad("a move that isn't legal there"));
187    }
188    let engine: String = b.engine.as_deref().unwrap_or("open-chess app").chars().take(60).collect();
189    let moves =
190        if b.moves.is_empty() { None } else { Some(serde_json::to_value(&b.moves).unwrap_or_default()) };
191    let c = ctx.db.conn().await?;
192    not_playing(&ctx, &*c, me).await?;
193    c.execute(
194        &ctx.db.sql(
195            "INSERT INTO chess.position_hints (key, player_id, best, score_cp, depth, engine, moves)
196             VALUES ($1, $2, $3, $4, $5, $6, $7)
197             ON CONFLICT (key, player_id) DO UPDATE SET best = EXCLUDED.best, score_cp = EXCLUDED.score_cp,
198                 depth = EXCLUDED.depth, engine = EXCLUDED.engine, moves = EXCLUDED.moves, at = now()
199               WHERE EXCLUDED.depth >= chess.position_hints.depth",
200        ),
201        &[&position_key(&game), &me, &b.best, &b.score_cp, &b.depth, &engine, &moves],
202    )
203    .await?;
204    Ok(ok())
205}
206
207/// `GET /api/esl/position/moves?fen=`: every legal move, each with the
208/// best value the dictionary has for it, from the mover's view, and
209/// White's odds after it: the worker's scoring of this position's moves
210/// first, then the evaluation stored for the position the move leads to,
211/// then an app's hint. `null` where nothing is known.
212pub async fn position_moves(
213    State(ctx): State<Ctx>,
214    who: Who,
215    Query(q): Query<PositionQuery>,
216) -> ApiResult<Response> {
217    let game = parse_fen(&q.fen)?;
218    let c = ctx.db.conn().await?;
219    not_playing(&ctx, &*c, who.0.player).await?;
220    let key = position_key(&game);
221    let legal = game.legal_moves();
222    // (uci, the position after it)
223    let mut children = Vec::with_capacity(legal.len());
224    for m in &legal {
225        let mut g = game.clone();
226        if g.play_uci(m).is_ok() {
227            children.push((m.clone(), g));
228        }
229    }
230    let child_keys: Vec<String> = children.iter().map(|(_, g)| position_key(g)).collect();
231    let mut known: std::collections::HashMap<String, (i32, i32)> = std::collections::HashMap::new();
232    for r in c
233        .query(
234            &ctx.db.sql("SELECT key, score_cp, depth FROM chess.positions WHERE key = ANY($1) AND score_cp IS NOT NULL"),
235            &[&child_keys],
236        )
237        .await?
238    {
239        known.insert(r.get(0), (r.get(1), r.get(2)));
240    }
241    let moves_of = |row: Option<tokio_postgres::Row>| {
242        row.and_then(|r| r.get::<_, Option<serde_json::Value>>(0))
243            .and_then(|v| v.as_array().cloned())
244            .unwrap_or_default()
245    };
246    let worker = moves_of(
247        c.query_opt(&ctx.db.sql("SELECT moves FROM chess.positions WHERE key = $1"), &[&key]).await?,
248    );
249    let hints = moves_of(
250        c.query_opt(
251            &ctx.db.sql(
252                "SELECT moves FROM chess.position_hints WHERE key = $1 AND moves IS NOT NULL ORDER BY depth DESC LIMIT 1",
253            ),
254            &[&key],
255        )
256        .await?,
257    );
258    let find = |list: &[serde_json::Value], uci: &str, field: &str| {
259        list.iter().find(|m| m["uci"] == uci).and_then(|m| m[field].as_i64()).map(|v| v as i32)
260    };
261    let moves: Vec<serde_json::Value> = children
262        .iter()
263        .zip(&child_keys)
264        .map(|((uci, after), ck)| {
265            let (cp, depth, source) = if let Some(cp) = find(&worker, uci, "cp") {
266                (Some(cp), find(&worker, uci, "depth"), "engine")
267            } else if let Some(&(cp, d)) = known.get(ck) {
268                // Stored for the side to move after it: the other side.
269                (Some(-cp), Some(d), "after")
270            } else if let Some(cp) = find(&hints, uci, "scoreCp") {
271                (Some(cp), None, "hint")
272            } else {
273                (None, None, "unknown")
274            };
275            let wdl = cp.map(|v| wdl_for(after, -v));
276            json!({"uci": uci, "san": chess_analysis::explain_san(game.board(), uci).map(|e| e.san), "scoreCp": cp,
277                   "depth": depth, "source": source, "wdl": wdl})
278        })
279        .collect();
280    Ok(Json(json!({"key": key, "moves": moves})).into_response())
281}
282
283/// `GET /api/esl/tablebase?fen=`: Lichess's tablebase answer (category,
284/// distance to zeroing and to mate, and every move's result), cached.
285/// Like the position dictionary, refused while you're playing here.
286pub async fn tablebase(
287    State(ctx): State<Ctx>,
288    who: Who,
289    Query(q): Query<PositionQuery>,
290) -> ApiResult<Response> {
291    let game = Game::new(Some(q.fen.trim())).map_err(|e| ApiError::bad(format!("bad FEN: {e:?}")))?;
292    let pieces = game.board().occupied().len();
293    if pieces > 7 {
294        return Err(ApiError::bad("tablebases cover positions with seven pieces or fewer"));
295    }
296    let base = ctx.cfg.tablebase_url.trim().trim_end_matches('/');
297    if base.is_empty() {
298        return Err(ApiError::new(
299            axum::http::StatusCode::SERVICE_UNAVAILABLE,
300            "tablebases are off on this server",
301        ));
302    }
303    let c = ctx.db.conn().await?;
304    if !model::ongoing_games(&ctx.db, &*c, who.0.player).await?.is_empty() {
305        return Err(ApiError::forbidden("tablebase lookups are off while you have a game in progress"));
306    }
307    let key = position_key(&game);
308    if let Some(r) =
309        c.query_opt(&ctx.db.sql("SELECT answer FROM chess.tablebase WHERE key = $1"), &[&key]).await?
310    {
311        let mut v: serde_json::Value = r.get(0);
312        v["cached"] = true.into();
313        return Ok(Json(v).into_response());
314    }
315    // The tablebase wants a full FEN; the key's counters don't matter.
316    let fen = format!("{key} 0 1");
317    let resp = crate::vision::http()
318        .get(format!("{base}/standard"))
319        .query(&[("fen", fen.as_str())])
320        .timeout(std::time::Duration::from_secs(10))
321        .send()
322        .await
323        .map_err(|e| {
324            ApiError::new(axum::http::StatusCode::BAD_GATEWAY, format!("tablebase unreachable: {e}"))
325        })?;
326    if !resp.status().is_success() {
327        return Err(ApiError::new(
328            axum::http::StatusCode::BAD_GATEWAY,
329            format!("tablebase answered {}", resp.status()),
330        ));
331    }
332    let answer: serde_json::Value = resp.json().await.map_err(|e| {
333        ApiError::new(axum::http::StatusCode::BAD_GATEWAY, format!("tablebase answer unreadable: {e}"))
334    })?;
335    c.execute(
336        &ctx.db.sql("INSERT INTO chess.tablebase (key, answer) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING"),
337        &[&key, &answer],
338    )
339    .await?;
340    Ok(Json(answer).into_response())
341}
342
343#[derive(Deserialize)]
344pub struct RecogniseBody {
345    /// A `data:image/...;base64,` URL or bare base64.
346    image: String,
347}
348
349/// Read a board from a photo: with our board reader if there is one,
350/// asking the vision language model only when it isn't sure (or there is
351/// no reader). A photo that wasn't read for sure is kept for the Review
352/// page (`photoId` in the answer) unless CHESS_VISION_REVIEW is off.
353pub async fn recognise(
354    State(ctx): State<Ctx>,
355    who: Who,
356    Json(b): Json<RecogniseBody>,
357) -> ApiResult<Response> {
358    let me = who.0.player;
359    if ctx.reader.is_none() && !vision::enabled(&ctx.cfg) {
360        return Err(ApiError::new(
361            axum::http::StatusCode::SERVICE_UNAVAILABLE,
362            "photo reading isn't set up on this server (CHESS_VISION_LOCAL_MODEL or CHESS_VISION_ENDPOINT)",
363        ));
364    }
365    if b.image.len() > 8 * 1024 * 1024 {
366        return Err(ApiError::bad("the photo is too large: send it at 1600 pixels or less"));
367    }
368    let (bytes, mime) = vision::decode_image(&b.image).map_err(|e| ApiError::bad(format!("{e:#}")))?;
369
370    // Our reader first.
371    let local = match ctx.reader.clone() {
372        Some(reader) => {
373            let photo = bytes.clone();
374            let started = std::time::Instant::now();
375            let r = tokio::task::spawn_blocking(move || reader.read_encoded(&photo))
376                .await
377                .map_err(|e| ApiError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
378                .map_err(|e| ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, e.to_string()))?;
379            tracing::debug!(
380                ms = started.elapsed().as_millis() as u64,
381                confidence = r.confidence,
382                "photo read"
383            );
384            Some(r)
385        }
386        None => None,
387    };
388    let model_name = ctx.reader.as_ref().map(|r| r.card().name.clone()).filter(|n| !n.is_empty());
389    if let Some(r) = &local
390        && r.is_sure(ctx.cfg.vision_min_confidence)
391    {
392        return Ok(Json(local_answer(r, model_name.as_deref(), None)).into_response());
393    }
394
395    // Not sure (or no reader): the vision language model's opinion.
396    let llm =
397        if vision::enabled(&ctx.cfg) { Some(vision::read_board(&ctx.cfg, &b.image).await) } else { None };
398    let reason = match &local {
399        None => "no_model",
400        Some(r) if !r.plausible => "implausible",
401        Some(_) => "unsure",
402    };
403    let photo_id = if ctx.cfg.vision_review {
404        keep_photo(&ctx, me, &bytes, &mime, reason, local.as_ref(), model_name.as_deref(), llm.as_ref())
405            .await
406            .unwrap_or_else(|e| {
407                tracing::warn!("keeping a photo for review: {e:#}");
408                None
409            })
410    } else {
411        None
412    };
413    let llm_answer = |l: &vision::Reading| {
414        let mut v = llm_json(l, &ctx.cfg.vision_model);
415        v["source"] = json!("llm");
416        v["photoId"] = json!(photo_id);
417        v["review"] = json!(photo_id.is_some());
418        v
419    };
420    match (local, llm) {
421        // The reader's reading can't be a game position but the model's
422        // can: give the model's.
423        (Some(r), Some(Ok(l))) if !r.plausible => Ok(Json(llm_answer(&l)).into_response()),
424        // Otherwise the reader's (if unsure), with the model's alongside.
425        (Some(r), llm) => {
426            let mut v = local_answer(&r, model_name.as_deref(), photo_id);
427            if let Some(Ok(l)) = llm {
428                v["llm"] = llm_json(&l, &ctx.cfg.vision_model);
429            }
430            Ok(Json(v).into_response())
431        }
432        (None, Some(Ok(l))) => Ok(Json(llm_answer(&l)).into_response()),
433        (None, Some(Err(e))) => {
434            Err(ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))
435        }
436        (None, None) => {
437            Err(ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, "the photo couldn't be read"))
438        }
439    }
440}
441
442fn local_answer(
443    r: &chess_vision::BoardReading,
444    model: Option<&str>,
445    photo_id: Option<i64>,
446) -> serde_json::Value {
447    json!({
448        "source": "model",
449        "placement": r.placement,
450        // The reader always reads from White's side; it doesn't say where
451        // the camera was.
452        "whiteAtBottom": null,
453        "sideToMove": null,
454        "confidence": r.confidence,
455        "unsureSquares": r.unsure_squares,
456        "plausible": r.plausible,
457        "squares": r.squares,
458        "model": model,
459        "photoId": photo_id,
460        "review": photo_id.is_some(),
461    })
462}
463
464fn llm_json(l: &vision::Reading, model: &str) -> serde_json::Value {
465    json!({
466        "placement": l.placement,
467        "whiteAtBottom": l.white_at_bottom,
468        "sideToMove": l.side_to_move,
469        "confidence": l.confidence,
470        "unsureSquares": l.unsure_squares,
471        "model": model,
472    })
473}
474
475/// Keep a photo for the Review page; its id, or `None` when the review
476/// queue is full.
477#[allow(clippy::too_many_arguments)]
478async fn keep_photo(
479    ctx: &Ctx,
480    player: i64,
481    bytes: &[u8],
482    mime: &str,
483    reason: &str,
484    local: Option<&chess_vision::BoardReading>,
485    model: Option<&str>,
486    llm: Option<&anyhow::Result<vision::Reading>>,
487) -> anyhow::Result<Option<i64>> {
488    let c = ctx.db.conn().await?;
489    let waiting: i64 = c
490        .query_one(
491            &ctx.db.sql("SELECT count(*) FROM chess.board_photos WHERE label IS NULL AND NOT unreadable"),
492            &[],
493        )
494        .await?
495        .get(0);
496    if waiting >= ctx.cfg.vision_review_max {
497        return Ok(None);
498    }
499    let reading = local.map(|r| {
500        json!({"placement": r.placement, "confidence": r.confidence, "unsure_squares": r.unsure_squares,
501               "plausible": r.plausible, "model": model})
502    });
503    let (suggestion, failed) = match llm {
504        Some(Ok(l)) => (
505            Some(json!({"placement": l.placement, "white_at_bottom": l.white_at_bottom,
506            "side_to_move": l.side_to_move, "confidence": l.confidence, "unsure_squares": l.unsure_squares,
507            "model": ctx.cfg.vision_model, "at": chrono::Utc::now()})),
508            None,
509        ),
510        Some(Err(e)) => (None, Some(format!("{e:#}").chars().take(300).collect::<String>())),
511        None => (None, None),
512    };
513    let row = c
514        .query_one(
515            &ctx.db.sql(
516                "INSERT INTO chess.board_photos (player_id, image, mime, reason, reading, suggestion, suggestion_failed)
517                 VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id",
518            ),
519            &[&player, &bytes, &mime, &reason, &reading, &suggestion, &failed],
520        )
521        .await?;
522    Ok(Some(row.get(0)))
523}
524
525#[derive(Deserialize)]
526pub struct ConfirmBody {
527    /// The position the photo shows, as FEN (only the placement is used,
528    /// from White's side).
529    placement: String,
530    /// The full position, when the app knows it (side to move, castling):
531    /// what goes into the position dictionary. Without it, both sides to
532    /// move are queued.
533    #[serde(default)]
534    fen: Option<String>,
535}
536
537/// Queue a confirmed photo's position(s) in the dictionary for the
538/// fair-play worker to evaluate (counting the photos of each).
539pub async fn queue_photo_positions(
540    db: &crate::db::Db,
541    c: &impl tokio_postgres::GenericClient,
542    games: &[Game],
543) -> anyhow::Result<()> {
544    let sql = db.sql(
545        "INSERT INTO chess.positions (key, score_cp, depth, engine, seen, photos) VALUES ($1, NULL, 0, NULL, 0, 1)
546         ON CONFLICT (key) DO UPDATE SET photos = chess.positions.photos + 1",
547    );
548    for g in games.iter().filter(|g| g.outcome().is_none()) {
549        c.execute(&sql, &[&position_key(g)]).await?;
550    }
551    Ok(())
552}
553
554/// `POST /api/esl/photos/{id}/confirm`: the player who sent a photo says
555/// what it showed (they picked the move it matched, or finished setting the
556/// position up in the editor). A label for training, marked as the
557/// player's: a reviewer's label always wins.
558pub async fn confirm_photo(
559    State(ctx): State<Ctx>,
560    who: Who,
561    Path(id): Path<i64>,
562    Json(b): Json<ConfirmBody>,
563) -> ApiResult<Response> {
564    let grid = chess_analysis::photo::parse_placement(&b.placement)
565        .ok_or_else(|| ApiError::bad("that isn't a board: eight ranks of eight squares"))?;
566    let placement = chess_analysis::photo::placement(&grid);
567    let c = ctx.db.conn().await?;
568    let n = c
569        .execute(
570            &ctx.db.sql(
571                "UPDATE chess.board_photos SET label = $3, label_source = 'player', labelled_by = $2, labelled_at = now()
572                  WHERE id = $1 AND player_id = $2 AND NOT unreadable AND coalesce(label_source, 'player') = 'player'",
573            ),
574            &[&id, &who.0.player, &placement],
575        )
576        .await?;
577    if n == 0 {
578        return Err(ApiError::not_found("photo"));
579    }
580    // The position joins the dictionary: the given one if it matches the
581    // placement, else each side to move.
582    let given = b
583        .fen
584        .as_deref()
585        .and_then(|f| Game::new(Some(f.trim())).ok())
586        .filter(|g| g.fen().split_whitespace().next() == Some(placement.as_str()));
587    let games = match given {
588        Some(g) => vec![g],
589        None => chess_analysis::dictionary::photo_positions(&grid, None),
590    };
591    if let Err(e) = queue_photo_positions(&ctx.db, &*c, &games).await {
592        tracing::warn!("queueing a photo's position: {e:#}");
593    }
594    Ok(ok())
595}