Skip to main content

open_chess_server/http/
otb.rs

1//! Games on a real board, recorded live from the app.
2//!
3//! One of the players records the game on their phone: from a connected
4//! board (Chessnut), from photos after each move, or by hand. Each move is
5//! sent as it's made, so the game is followed live like any other (the
6//! public stream, the live board overlay for a video stream). It ends when
7//! the recorder says, or on the board (mate, stalemate…). A rated one is
8//! rated, and reaches the feed, once the other player confirms the result
9//! (`POST /api/games/{id}/confirm`), as with a game played on one device.
10//!
11//! * `POST /api/otb`: start one.
12//! * `POST /api/otb/{id}/move`, `/undo`, `/end`.
13//! * `GET /broadcast/{id}?key=`: the live board overlay (board, last moves,
14//!   players, result) for a streaming app's browser source, and
15//!   `GET /api/broadcast/{id}/events?key=` behind it. The key is the
16//!   secret the recorder was given; nothing else about the game is open.
17
18use std::collections::HashMap;
19use std::convert::Infallible;
20use std::time::Duration;
21
22use axum::Json;
23use axum::extract::{Path, Query, State};
24use axum::http::header;
25use axum::response::sse::{Event, KeepAlive, Sse};
26use axum::response::{IntoResponse, Response};
27use chess_core::{Game, Side, Speed, Status};
28use futures_util::stream;
29use serde::Deserialize;
30use serde_json::{Value, json};
31use tokio::sync::broadcast::error::RecvError;
32use tokio::sync::mpsc;
33use tokio_postgres::Transaction;
34
35use crate::app::{Ctx, Who};
36use crate::db::{Note, notify};
37use crate::error::{ApiError, ApiResult};
38use crate::model::{self, GameRow};
39use crate::play;
40
41#[derive(Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Start {
44    white: String,
45    black: String,
46    /// The rating pool: "blitz", "rapid", "classical"…
47    speed: String,
48    #[serde(default)]
49    rated: bool,
50    /// "chessnut", "photos" or "manual".
51    board: String,
52    /// Make a link for the live board overlay.
53    #[serde(default)]
54    broadcast: bool,
55}
56
57struct Otb {
58    recorder: i64,
59    opponent: i64,
60    rated_requested: bool,
61    broadcast_key: Option<String>,
62}
63
64async fn otb(ctx: &Ctx, tx: &impl tokio_postgres::GenericClient, id: &str) -> anyhow::Result<Option<Otb>> {
65    Ok(tx
66        .query_opt(
67            &ctx.db.sql(
68                "SELECT recorder_id, opponent_id, rated_requested, broadcast_key FROM chess.otb_games WHERE game_id = $1",
69            ),
70            &[&id],
71        )
72        .await?
73        .map(|r| Otb { recorder: r.get(0), opponent: r.get(1), rated_requested: r.get(2), broadcast_key: r.get(3) }))
74}
75
76/// `POST /api/otb`: the recorder is one of the two players; the other
77/// confirms the result.
78pub async fn start(State(ctx): State<Ctx>, who: Who, Json(b): Json<Start>) -> ApiResult<Response> {
79    let id = who.need(&["board:play"])?.clone();
80    let speed =
81        Speed::from_key(&b.speed).ok_or_else(|| ApiError::bad(format!("unknown speed {}", b.speed)))?;
82    if !matches!(b.board.as_str(), "chessnut" | "photos" | "manual") {
83        return Err(ApiError::bad("board is chessnut, photos or manual"));
84    }
85    let mut c = ctx.db.conn().await?;
86    let tx = c.transaction().await?;
87    let me = model::ensure_account(&ctx.db, &tx, id.player).await?;
88    let find = async |name: &str| model::account_by_name(&ctx.db, &tx, name).await;
89    let white = find(&b.white).await?.ok_or_else(|| ApiError::not_found(&format!("player {}", b.white)))?;
90    let black = find(&b.black).await?.ok_or_else(|| ApiError::not_found(&format!("player {}", b.black)))?;
91    if white.player_id == black.player_id {
92        return Err(ApiError::bad("white and black are the same player"));
93    }
94    let opponent = if white.player_id == me.player_id {
95        &black
96    } else if black.player_id == me.player_id {
97        &white
98    } else {
99        return Err(ApiError::bad("you record games you play in"));
100    };
101    if opponent.bot {
102        return Err(ApiError::bad("the computer isn't played on a real board here"));
103    }
104    let perf = speed.key();
105    let (wr, _, _) = model::rating(&ctx.db, &tx, white.player_id, perf).await?;
106    let (br, _, _) = model::rating(&ctx.db, &tx, black.player_id, perf).await?;
107    let game = Game::new(None).map_err(|e| anyhow::anyhow!("{e}"))?;
108    let game_id = model::new_id();
109    tx.execute(
110        &ctx.db.sql(
111            "INSERT INTO chess.games (id, white_id, black_id, variant, rated, source, speed, status, fen,
112                    white_rating, black_rating, white_provisional, black_provisional, created_at, turn_started_at)
113             VALUES ($1, $2, $3, 'standard', false, 'otb', $4, 20, $5, $6, $7, $8, $9, clock_timestamp(), clock_timestamp())",
114        ),
115        &[
116            &game_id,
117            &white.player_id,
118            &black.player_id,
119            &perf,
120            &game.fen(),
121            &wr.int(),
122            &br.int(),
123            &wr.provisional(),
124            &br.provisional(),
125        ],
126    )
127    .await?;
128    let key = b.broadcast.then(|| crate::auth::random_token(24));
129    tx.execute(
130        &ctx.db.sql(
131            "INSERT INTO chess.otb_games (game_id, recorder_id, opponent_id, board, rated_requested, broadcast_key)
132             VALUES ($1, $2, $3, $4, $5, $6)",
133        ),
134        &[&game_id, &me.player_id, &opponent.player_id, &b.board, &b.rated, &key],
135    )
136    .await?;
137    notify(&tx, &ctx.db, &Note::Game(game_id.clone())).await?;
138    tx.commit().await?;
139    ctx.metrics.games_started.inc();
140    let base = ctx.cfg.public_url();
141    Ok(Json(json!({
142        "id": game_id,
143        "url": format!("{base}/{game_id}"),
144        "broadcastUrl": key.as_ref().map(|k| format!("{base}/broadcast/{game_id}?key={k}")),
145    }))
146    .into_response())
147}
148
149/// The game, locked, if `player` records it and it's still going.
150async fn recording(
151    ctx: &Ctx,
152    tx: &Transaction<'_>,
153    id: &str,
154    player: i64,
155) -> ApiResult<(GameRow, Otb, Game)> {
156    let o = otb(ctx, tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
157    if o.recorder != player {
158        return Err(ApiError::not_found("game"));
159    }
160    let row = model::game_for_update(&ctx.db, tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
161    if !row.status.is_ongoing() {
162        return Err(ApiError::conflict("the game is over"));
163    }
164    let game =
165        Game::from_moves(row.initial_fen.as_deref(), &row.moves).map_err(|e| anyhow::anyhow!("{e}"))?;
166    Ok((row, o, game))
167}
168
169#[derive(Deserialize)]
170pub struct Move {
171    uci: String,
172}
173
174/// `POST /api/otb/{id}/move`: the next move, as played on the board.
175pub async fn make_move(
176    State(ctx): State<Ctx>,
177    who: Who,
178    Path(id): Path<String>,
179    Json(m): Json<Move>,
180) -> ApiResult<Response> {
181    let mut c = ctx.db.conn().await?;
182    let tx = c.transaction().await?;
183    let (row, o, mut game) = recording(&ctx, &tx, &id, who.0.player).await?;
184    let played = game.play_uci(m.uci.trim()).map_err(|e| ApiError::bad(e.to_string()))?;
185    let mut move_ms = row.move_ms.clone();
186    let mut clock_ms = row.clock_ms.clone();
187    move_ms.push(row.elapsed_ms.max(0));
188    clock_ms.push(model::NO_CLOCK_MS);
189    let moves =
190        if row.moves.is_empty() { played.uci.clone() } else { format!("{} {}", row.moves, played.uci) };
191    tx.execute(
192        &ctx.db.sql(
193            "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, move_ms = $5, clock_ms = $6,
194                    turn_started_at = clock_timestamp(), last_move_at = clock_timestamp()
195              WHERE id = $1",
196        ),
197        &[&id, &moves, &(row.ply + 1), &game.fen(), &move_ms, &clock_ms],
198    )
199    .await?;
200    if let Some(o2) = game.outcome() {
201        let row = model::game(&ctx.db, &tx, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
202        end_game(&ctx, &tx, &row, &o, o2.status, o2.winner).await?;
203    } else {
204        notify(&tx, &ctx.db, &Note::Game(id.clone())).await?;
205    }
206    tx.commit().await?;
207    Ok(Json(json!({"san": played.san, "fen": game.fen(), "status": game.outcome().map_or("started", |o| o.status.name())}))
208        .into_response())
209}
210
211#[derive(Deserialize)]
212pub struct Undo {
213    #[serde(default = "one")]
214    plies: usize,
215}
216
217fn one() -> usize {
218    1
219}
220
221/// `POST /api/otb/{id}/undo`: take back moves recorded wrongly.
222pub async fn undo(
223    State(ctx): State<Ctx>,
224    who: Who,
225    Path(id): Path<String>,
226    Json(u): Json<Undo>,
227) -> ApiResult<Response> {
228    let mut c = ctx.db.conn().await?;
229    let tx = c.transaction().await?;
230    let (row, _, mut game) = recording(&ctx, &tx, &id, who.0.player).await?;
231    let n = u.plies.min(game.ply());
232    game.undo(n);
233    let keep = game.ply();
234    let moves = game.uci_moves().join(" ");
235    let move_ms: Vec<i64> = row.move_ms.iter().take(keep).copied().collect();
236    let clock_ms: Vec<i64> = row.clock_ms.iter().take(keep).copied().collect();
237    tx.execute(
238        &ctx.db.sql(
239            "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, move_ms = $5, clock_ms = $6,
240                    turn_started_at = clock_timestamp()
241              WHERE id = $1",
242        ),
243        &[&id, &moves, &i32::try_from(keep).unwrap_or(0), &game.fen(), &move_ms, &clock_ms],
244    )
245    .await?;
246    notify(&tx, &ctx.db, &Note::Game(id.clone())).await?;
247    tx.commit().await?;
248    Ok(Json(json!({"fen": game.fen(), "ply": keep})).into_response())
249}
250
251#[derive(Deserialize)]
252pub struct End {
253    /// "resign", "draw", "outoftime" or "aborted".
254    status: String,
255    #[serde(default)]
256    winner: Option<String>,
257}
258
259/// `POST /api/otb/{id}/end`: a result decided off the board (resignation,
260/// an agreed draw, a flag on a real clock), or the game abandoned.
261pub async fn end(
262    State(ctx): State<Ctx>,
263    who: Who,
264    Path(id): Path<String>,
265    Json(asked): Json<End>,
266) -> ApiResult<Response> {
267    let mut c = ctx.db.conn().await?;
268    let tx = c.transaction().await?;
269    let (row, rec, game) = recording(&ctx, &tx, &id, who.0.player).await?;
270    let winner = match asked.winner.as_deref() {
271        None | Some("") => None,
272        Some(w) => Some(Side::from_name(w).ok_or_else(|| ApiError::bad(format!("winner {w}?")))?),
273    };
274    let (status, winner) = match (Status::from_name(&asked.status), winner) {
275        (Some(s @ (Status::Resign | Status::Outoftime)), Some(w)) => {
276            if s == Status::Outoftime && game.cannot_win(w) { (s, None) } else { (s, Some(w)) }
277        }
278        (Some(Status::Draw), _) => (Status::Draw, None),
279        (Some(Status::Aborted), _) => (Status::Aborted, None),
280        _ => return Err(ApiError::bad("status is resign or outoftime (with a winner), draw or aborted")),
281    };
282    let pending = end_game(&ctx, &tx, &row, &rec, status, winner).await?;
283    tx.commit().await?;
284    Ok(Json(json!({"status": status.name(), "winner": winner.map(Side::name), "confirmation": if pending { "pending" } else { "none" }}))
285        .into_response())
286}
287
288/// Finish a real-board game. A rated one waits for the other player (no
289/// rating, not in the feed) until they confirm. Whether it waits.
290async fn end_game(
291    ctx: &Ctx,
292    tx: &Transaction<'_>,
293    row: &GameRow,
294    o: &Otb,
295    status: Status,
296    winner: Option<Side>,
297) -> anyhow::Result<bool> {
298    play::finish(ctx, tx, row, status, winner).await?;
299    let waits = o.rated_requested && !status.is_void() && row.ply >= 2;
300    if waits {
301        tx.execute(&ctx.db.sql("UPDATE chess.games SET feed_at = NULL WHERE id = $1"), &[&row.id]).await?;
302        tx.execute(
303            &ctx.db.sql("UPDATE chess.otb_games SET confirm = 'pending' WHERE game_id = $1"),
304            &[&row.id],
305        )
306        .await?;
307        let recorder = row.player(if row.white.player_id == o.recorder { Side::White } else { Side::Black });
308        crate::push::notify(
309            ctx,
310            o.opponent,
311            "A result to confirm".into(),
312            format!("{} recorded your game on a real board. Confirm it in the app.", recorder.username),
313            Some(row.id.clone()),
314            None,
315        );
316    }
317    Ok(waits)
318}
319
320/// The other player's answer to a real-board game's result, from
321/// `POST /api/games/{id}/confirm`. `None`: no such game waiting for them.
322pub(crate) async fn confirm(ctx: &Ctx, player: i64, id: &str, accept: bool) -> ApiResult<Option<Value>> {
323    let mut c = ctx.db.conn().await?;
324    let tx = c.transaction().await?;
325    let pending = tx
326        .query_opt(
327            &ctx.db.sql(
328                "SELECT confirm FROM chess.otb_games WHERE game_id = $1 AND opponent_id = $2 FOR UPDATE",
329            ),
330            &[&id, &player],
331        )
332        .await?;
333    let Some(p) = pending else { return Ok(None) };
334    let state: String = p.get(0);
335    if state != "pending" {
336        return Err(ApiError::conflict(format!(
337            "already {}",
338            if state == "none" { "recorded" } else { &state }
339        )));
340    }
341    let row = model::game_for_update(&ctx.db, &tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
342    let diff = if accept {
343        let rated = GameRow { rated: true, ..row.clone() };
344        let (wd, bd) = play::rate(ctx, &tx, &rated, row.winner).await?;
345        tx.execute(
346            &ctx.db.sql(
347                "UPDATE chess.games SET rated = true, white_rating_diff = $2, black_rating_diff = $3, feed_at = clock_timestamp()
348                  WHERE id = $1",
349            ),
350            &[&id, &wd, &bd],
351        )
352        .await?;
353        if row.white.player_id == player { wd } else { bd }
354    } else {
355        tx.execute(&ctx.db.sql("UPDATE chess.games SET feed_at = clock_timestamp() WHERE id = $1"), &[&id])
356            .await?;
357        None
358    };
359    tx.execute(
360        &ctx.db.sql("UPDATE chess.otb_games SET confirm = $2 WHERE game_id = $1"),
361        &[&id, &(if accept { "confirmed" } else { "declined" })],
362    )
363    .await?;
364    notify(&tx, &ctx.db, &Note::Game(id.to_owned())).await?;
365    tx.commit().await?;
366    Ok(Some(json!({"gameId": id, "status": if accept { "rated" } else { "declined" }, "ratingDiff": diff})))
367}
368
369/// Real-board games waiting for `player` to confirm, for `/api/sync`.
370pub(crate) async fn waiting_for(
371    ctx: &Ctx,
372    c: &impl tokio_postgres::GenericClient,
373    player: i64,
374) -> anyhow::Result<Vec<Value>> {
375    let rows = c
376        .query(
377            &ctx.db.sql(&format!(
378                "{} JOIN chess.otb_games o ON o.game_id = g.id
379                  WHERE o.opponent_id = $1 AND o.confirm = 'pending' ORDER BY g.finished_at",
380                model::GAME_SELECT
381            )),
382            &[&player],
383        )
384        .await?;
385    Ok(rows
386        .iter()
387        .map(GameRow::from_row)
388        .map(|g| {
389            let recorder = if g.white.player_id == player { &g.black } else { &g.white };
390            json!({"id": g.id, "kind": "otb", "white": g.white.username, "black": g.black.username,
391                   "winner": g.winner.map(Side::name), "status": g.status.name(), "speed": g.perf_key(),
392                   "moves": g.ply, "uploadedBy": recorder.username, "createdAt": g.created_ms})
393        })
394        .collect())
395}
396
397/// What the overlay shows.
398fn view(row: &GameRow) -> Value {
399    let game = Game::from_moves(row.initial_fen.as_deref(), &row.moves).ok();
400    let sans: Vec<String> = game.as_ref().map(|g| g.sans().to_vec()).unwrap_or_default();
401    let first = sans.len().saturating_sub(10);
402    json!({
403        "id": row.id, "fen": row.fen, "lastMove": row.last_move(), "ply": row.ply,
404        "sans": &sans[first..], "firstPly": first,
405        "white": {"name": row.white.username, "rating": row.white_rating},
406        "black": {"name": row.black.username, "rating": row.black_rating},
407        "status": row.status.name(), "winner": row.winner.map(Side::name),
408    })
409}
410
411/// The game, if `key` is its broadcast key.
412async fn broadcast_game(ctx: &Ctx, id: &str, key: Option<&String>) -> ApiResult<GameRow> {
413    let c = ctx.db.conn().await?;
414    let o = otb(ctx, &*c, id).await?.ok_or_else(|| ApiError::not_found("broadcast"))?;
415    let ok = match (o.broadcast_key.as_deref(), key) {
416        (Some(k), Some(given)) => crate::auth::hash_key(k) == crate::auth::hash_key(given),
417        _ => false,
418    };
419    if !ok {
420        return Err(ApiError::not_found("broadcast"));
421    }
422    model::game(&ctx.db, &*c, id).await?.ok_or_else(|| ApiError::not_found("broadcast"))
423}
424
425/// `GET /api/broadcast/{id}/events?key=`: the game as server-sent `state`
426/// events, now and after every move.
427pub async fn events(
428    State(ctx): State<Ctx>,
429    Path(id): Path<String>,
430    Query(q): Query<HashMap<String, String>>,
431) -> ApiResult<Response> {
432    let row = broadcast_game(&ctx, &id, q.get("key")).await?;
433    let mut notes = ctx.db.subscribe();
434    let (tx, rx) = mpsc::channel::<Event>(64);
435    let first = Event::default().event("state").json_data(view(&row)).map_err(|e| anyhow::anyhow!("{e}"))?;
436    let _ = tx.send(first).await;
437    let ctx2 = ctx.clone();
438    tokio::spawn(async move {
439        loop {
440            match notes.recv().await {
441                Ok(Note::Game(g)) if g == id => {}
442                Ok(_) | Err(RecvError::Lagged(_)) => continue,
443                Err(RecvError::Closed) => return,
444            }
445            let Ok(c) = ctx2.db.conn().await else { continue };
446            let Ok(Some(row)) = model::game(&ctx2.db, &*c, &id).await else { continue };
447            let Ok(e) = Event::default().event("state").json_data(view(&row)) else { continue };
448            if tx.send(e).await.is_err() {
449                return;
450            }
451        }
452    });
453    let events =
454        stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|e| (Ok::<_, Infallible>(e), rx)) });
455    Ok(Sse::new(events).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response())
456}
457
458/// `GET /broadcast/{id}?key=`: the overlay page (`bg=transparent` for a
459/// streaming app's browser source; `theme=light`).
460pub async fn page(
461    State(ctx): State<Ctx>,
462    Path(id): Path<String>,
463    Query(q): Query<HashMap<String, String>>,
464) -> ApiResult<Response> {
465    broadcast_game(&ctx, &id, q.get("key")).await?;
466    let html = OVERLAY.replace("{{base}}", &ctx.cfg.public_url().replace('"', ""));
467    Ok(([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], html)
468        .into_response())
469}
470
471/// The overlay: the board, the last five moves, the players and the
472/// result, in the esports colours by default. Reads its id and key from
473/// its own address.
474const OVERLAY: &str = include_str!("overlay.html");