Skip to main content

open_chess_server/http/
challenges.rs

1//! Challenge endpoints.
2
3use std::time::Duration;
4
5use axum::Json;
6use axum::extract::{Path, State};
7use axum::response::{IntoResponse, Response};
8use bytes::Bytes;
9use serde_json::json;
10use tokio::sync::mpsc;
11use tracing::warn;
12
13use crate::app::{Ctx, Who};
14use crate::challenges::{self, Request};
15use crate::db::Note;
16use crate::error::{ApiError, ApiResult};
17use crate::http::{form, line, ndjson, ok};
18use crate::metrics::StreamGuard;
19use crate::model;
20
21const PLAY: &[&str] = &["challenge:write", "board:play", "bot:play"];
22
23/// `POST /api/challenge/ai`: play the computer (`level` 1–8), as on
24/// Lichess; the game starts at once.
25pub async fn ai(State(ctx): State<Ctx>, who: Who, body: Bytes) -> ApiResult<Response> {
26    let id = who.need(PLAY)?.clone();
27    let f = form(&body);
28    let level: u8 =
29        f.get("level").and_then(|l| l.trim().parse().ok()).ok_or_else(|| ApiError::bad("level is 1 to 8"))?;
30    let req = Request::parse(&f)?;
31    let ch = crate::ai::challenge(&ctx, &id, level, &req).await?;
32    let mine = if ch.challenger.player_id == id.player { ch.final_color } else { ch.final_color.opposite() };
33    let fen = chess_core::Game::new(ch.initial_fen.as_deref()).map(|g| g.fen()).unwrap_or_default();
34    Ok(Json(json!({
35        "id": ch.id,
36        "variant": {"key": ch.variant, "name": if ch.variant == "standard" { "Standard" } else { "From Position" }},
37        "speed": ch.speed().key(),
38        "perf": ch.speed().key(),
39        "rated": false,
40        "fen": fen,
41        "turns": 0,
42        "source": "ai",
43        "status": {"id": 20, "name": "started"},
44        "player": mine.name(),
45        "aiLevel": level,
46        "url": format!("{}/{}", ctx.cfg.public_url(), ch.id),
47    }))
48    .into_response())
49}
50
51pub async fn create(
52    State(ctx): State<Ctx>,
53    who: Who,
54    Path(username): Path<String>,
55    body: Bytes,
56) -> ApiResult<Response> {
57    let id = who.need(PLAY)?.clone();
58    let req = Request::parse(&form(&body))?;
59    let (ch, ratings) = challenges::create(&ctx, &id, &username, &req).await?;
60    let json = ch.json(ctx.cfg.public_url(), Some(id.player), ratings);
61    if !req.keep_alive || ch.status != "created" {
62        return Ok(Json(json).into_response());
63    }
64    // keepAliveStream: the challenge lives while this stream is open;
65    // when it's answered, `{"done": "<status>"}` and the stream ends.
66    let slot = ctx.slot(id.player, "challenge")?;
67    let (tx, rx) = mpsc::channel::<Bytes>(16);
68    let _ = tx.send(line(&json)).await;
69    tokio::spawn(async move {
70        keep_alive(ctx, ch.id.clone(), id.player, tx).await;
71        drop(slot);
72    });
73    Ok(ndjson(rx))
74}
75
76async fn keep_alive(ctx: Ctx, id: String, player: i64, tx: mpsc::Sender<Bytes>) {
77    let _g = StreamGuard::new(&ctx.metrics, "challenge");
78    let mut notes = ctx.db.subscribe();
79    let mut refresh = tokio::time::interval(Duration::from_secs(5));
80    let mut keep = tokio::time::interval(Duration::from_secs(6));
81    let done = |status: &str| line(&json!({"done": status}));
82    loop {
83        tokio::select! {
84            n = notes.recv() => {
85                let relevant = match &n {
86                    Ok(Note::User { player: p, event }) => *p == player
87                        && (event.pointer("/challenge/id").and_then(|v| v.as_str()) == Some(id.as_str())
88                            || event.pointer("/game/id").and_then(|v| v.as_str()) == Some(id.as_str())),
89                    Ok(Note::Resync) | Err(_) => true,
90                    _ => false,
91                };
92                if relevant {
93                    match challenges::status(&ctx, &id).await {
94                        Ok(Some(s)) if s != "created" => {
95                            let _ = tx.send(done(&s)).await;
96                            return;
97                        }
98                        Ok(_) => {}
99                        Err(e) => warn!("challenge stream: {e:#}"),
100                    }
101                }
102            }
103            _ = refresh.tick() => {
104                if let Err(e) = challenges::keep_alive(&ctx, &id).await {
105                    warn!("keeping challenge {id} alive: {e:#}");
106                }
107            }
108            _ = keep.tick() => {
109                if tx.send(Bytes::from_static(b"\n")).await.is_err() {
110                    break;
111                }
112            }
113        }
114    }
115    // The challenger went away: the challenge goes with them.
116    let who = crate::auth::Identity {
117        player,
118        token_id: 0,
119        scopes: Vec::new(),
120        admin: false,
121        manager: false,
122        expires_ms: 0,
123    };
124    let _ = challenges::cancel(&ctx, &who, &id).await;
125}
126
127pub async fn list(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
128    let id = who.need(&["challenge:read", "challenge:write", "board:play", "bot:play"])?;
129    let (incoming, outgoing) = challenges::list(&ctx, id.player).await?;
130    Ok(Json(chess_proto::ChallengeList { incoming, outgoing }).into_response())
131}
132
133pub async fn show(State(ctx): State<Ctx>, who: Who, Path(id): Path<String>) -> ApiResult<Response> {
134    let me = who.need(&["challenge:read", "challenge:write", "board:play", "bot:play"])?;
135    let c = ctx.db.conn().await?;
136    let ch =
137        model::challenge(&ctx.db, &*c, &id, false).await?.ok_or_else(|| ApiError::not_found("challenge"))?;
138    if ch.challenger.player_id != me.player && ch.dest.player_id != me.player {
139        return Err(ApiError::not_found("challenge"));
140    }
141    let ratings = model::challenge_ratings(&ctx.db, &*c, &ch).await?;
142    Ok(Json(ch.json(ctx.cfg.public_url(), Some(me.player), ratings)).into_response())
143}
144
145pub async fn accept(State(ctx): State<Ctx>, who: Who, Path(id): Path<String>) -> ApiResult<Response> {
146    let me = who.need(PLAY)?;
147    challenges::accept(&ctx, me, &id).await?;
148    Ok(ok())
149}
150
151pub async fn decline(
152    State(ctx): State<Ctx>,
153    who: Who,
154    Path(id): Path<String>,
155    body: Bytes,
156) -> ApiResult<Response> {
157    let me = who.need(PLAY)?;
158    let f = form(&body);
159    challenges::decline(&ctx, me, &id, f.get("reason").map(String::as_str)).await?;
160    Ok(ok())
161}
162
163pub async fn cancel(State(ctx): State<Ctx>, who: Who, Path(id): Path<String>) -> ApiResult<Response> {
164    let me = who.need(PLAY)?;
165    challenges::cancel(&ctx, me, &id).await?;
166    Ok(ok())
167}