Skip to main content

open_chess_server/
challenges.rs

1//! Challenges: create (optionally accepted at once with the opponent's
2//! token), accept, decline, cancel, list, and lapse.
3
4use std::collections::HashMap;
5
6use anyhow::Result;
7use chess_core::{Game, Side};
8use chess_proto::ChallengeEvent;
9use tokio_postgres::Transaction;
10
11use crate::app::Ctx;
12use crate::auth::Identity;
13use crate::db::{Note, notify};
14use crate::error::{ApiError, ApiResult};
15use crate::model::{self, Account, ChallengeRow};
16use crate::play;
17
18pub const RULES: &[&str] = &["noAbort", "noRematch", "noGiveTime", "noClaimWin", "noEarlyDraw"];
19
20/// Lichess's real-time clock limits: 0, ¼, ½, ¾, 1, 1½ minutes, then any
21/// whole number of minutes up to three hours.
22pub fn valid_limit(secs: u32) -> bool {
23    matches!(secs, 0 | 15 | 30 | 45 | 60 | 90) || (secs.is_multiple_of(60) && secs <= 10_800)
24}
25
26/// A parsed `POST /api/challenge/{username}` body.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Request {
29    pub rated: bool,
30    pub clock: Option<(u32, u32)>,
31    pub color: String,
32    pub variant: String,
33    pub fen: Option<String>,
34    pub rules: Vec<String>,
35    pub keep_alive: bool,
36    pub accept_by_token: Option<String>,
37}
38
39fn truthy(v: &str) -> bool {
40    matches!(v, "true" | "1" | "on" | "yes")
41}
42
43impl Request {
44    pub fn parse(form: &HashMap<String, String>) -> ApiResult<Request> {
45        let get = |k: &str| form.get(k).map(|s| s.trim()).filter(|s| !s.is_empty());
46        if get("days").is_some() {
47            return Err(ApiError::bad("Correspondence games aren't supported on this server"));
48        }
49        let clock = match (get("clock.limit"), get("clock.increment")) {
50            (None, None) => None,
51            (Some(l), Some(i)) => {
52                let l: u32 = l.parse().map_err(|_| ApiError::bad("clock.limit is a number of seconds"))?;
53                let i: u32 =
54                    i.parse().map_err(|_| ApiError::bad("clock.increment is a number of seconds"))?;
55                if !valid_limit(l) {
56                    return Err(ApiError::bad(
57                        "clock.limit: 0, 15, 30, 45, 60, 90 or whole minutes up to 10800",
58                    ));
59                }
60                if i > 60 {
61                    return Err(ApiError::bad("clock.increment is at most 60"));
62                }
63                if l == 0 && i == 0 {
64                    return Err(ApiError::bad("a clock needs time or an increment"));
65                }
66                Some((l, i))
67            }
68            _ => return Err(ApiError::bad("send both clock.limit and clock.increment, or neither")),
69        };
70        let color = get("color").unwrap_or("random").to_owned();
71        if !matches!(color.as_str(), "white" | "black" | "random") {
72            return Err(ApiError::bad("color is white, black or random"));
73        }
74        let fen = get("fen").map(str::to_owned);
75        let variant = match get("variant").unwrap_or(if fen.is_some() { "fromPosition" } else { "standard" })
76        {
77            "standard" if fen.is_none() => "standard",
78            "standard" | "fromPosition" => "fromPosition",
79            other => {
80                return Err(ApiError::bad(format!("The {other} variant isn't supported on this server")));
81            }
82        }
83        .to_owned();
84        if variant == "fromPosition" {
85            let f = fen.as_deref().ok_or_else(|| ApiError::bad("fromPosition needs a fen"))?;
86            Game::new(Some(f)).map_err(|e| ApiError::bad(e.to_string()))?;
87        }
88        let rated = get("rated").is_some_and(truthy);
89        if rated && variant == "fromPosition" {
90            return Err(ApiError::bad("Games from a position can't be rated"));
91        }
92        if rated && clock.is_none() {
93            return Err(ApiError::bad("Unlimited games can't be rated"));
94        }
95        let rules: Vec<String> = get("rules")
96            .map(|r| r.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect())
97            .unwrap_or_default();
98        if let Some(bad) = rules.iter().find(|r| !RULES.contains(&r.as_str())) {
99            return Err(ApiError::bad(format!("Unknown rule {bad}")));
100        }
101        Ok(Request {
102            rated,
103            clock,
104            color,
105            variant,
106            fen,
107            rules,
108            keep_alive: get("keepAliveStream").is_some_and(truthy),
109            accept_by_token: get("acceptByToken").map(str::to_owned),
110        })
111    }
112
113    fn final_color(&self) -> Side {
114        match self.color.as_str() {
115            "white" => Side::White,
116            "black" => Side::Black,
117            _ => {
118                if rand::random::<bool>() {
119                    Side::White
120                } else {
121                    Side::Black
122                }
123            }
124        }
125    }
126}
127
128async fn announce(ctx: &Ctx, tx: &Transaction<'_>, ch: &ChallengeRow, kind: &str, to: &[i64]) -> Result<()> {
129    let ratings = model::challenge_ratings(&ctx.db, tx, ch).await?;
130    for &p in to {
131        let json = ch.json(ctx.cfg.public_url(), Some(p), ratings);
132        let mut ev = serde_json::json!({"type": kind, "challenge": json});
133        if kind == ChallengeEvent::CREATED {
134            ev["compat"] = serde_json::json!({"bot": true, "board": true});
135        }
136        notify(tx, &ctx.db, &Note::User { player: p, event: ev }).await?;
137    }
138    Ok(())
139}
140
141fn can_play(ctx: &Ctx, a: &Account) -> ApiResult<()> {
142    if ctx.cfg.block_marked && a.marked {
143        return Err(ApiError::forbidden("This account is marked for a fair-play violation"));
144    }
145    Ok(())
146}
147
148/// Create a challenge. With `acceptByToken`, the opponent's token accepts
149/// it in the same transaction and the game starts at once.
150pub async fn create(
151    ctx: &Ctx,
152    who: &Identity,
153    dest_name: &str,
154    req: &Request,
155) -> ApiResult<(ChallengeRow, (i32, i32))> {
156    let mut c = ctx.db.conn().await?;
157    let tx = c.transaction().await?;
158    let me = model::ensure_account(&ctx.db, &tx, who.player).await?;
159    let dest =
160        model::account_by_name(&ctx.db, &tx, dest_name).await?.ok_or_else(|| ApiError::not_found("user"))?;
161    if dest.player_id == me.player_id {
162        return Err(ApiError::bad("You can't challenge yourself"));
163    }
164    can_play(ctx, &me)?;
165    can_play(ctx, &dest)?;
166    // Accepting on the opponent's behalf needs their token, with a scope
167    // that lets them play.
168    let acceptor = match &req.accept_by_token {
169        Some(t) => {
170            let id =
171                ctx.auth.token(t).await?.ok_or_else(|| ApiError::bad("acceptByToken isn't a valid token"))?;
172            if id.player != dest.player_id {
173                return Err(ApiError::bad("acceptByToken belongs to someone else"));
174            }
175            if !id.has_any(&["challenge:write", "board:play", "bot:play"]) {
176                return Err(ApiError::bad("acceptByToken lacks the challenge:write scope"));
177            }
178            true
179        }
180        None => false,
181    };
182    let id = model::new_id();
183    let ttl_secs = if req.clock.is_some() { ctx.cfg.challenge_ttl().as_secs_f64() } else { 7.0 * 86_400.0 };
184    tx.execute(
185        &ctx.db.sql(
186            "INSERT INTO chess.challenges (id, challenger_id, dest_id, variant, initial_fen, rated, clock_limit,
187                    clock_increment, color, final_color, rules, expires_at)
188             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now() + make_interval(secs => $12))",
189        ),
190        &[
191            &id,
192            &me.player_id,
193            &dest.player_id,
194            &req.variant,
195            &req.fen,
196            &req.rated,
197            &req.clock.map(|c| c.0 as i32),
198            &req.clock.map(|c| c.1 as i32),
199            &req.color,
200            &req.final_color().name(),
201            &req.rules,
202            &ttl_secs,
203        ],
204    )
205    .await?;
206    ctx.metrics.challenges.with_label_values(&["created"]).inc();
207    let mut ch =
208        model::challenge(&ctx.db, &tx, &id, true).await?.ok_or_else(|| ApiError::not_found("challenge"))?;
209    if acceptor {
210        accept_locked(ctx, &tx, &mut ch).await?;
211    } else {
212        announce(ctx, &tx, &ch, ChallengeEvent::CREATED, &[dest.player_id, me.player_id]).await?;
213    }
214    let ratings = model::challenge_ratings(&ctx.db, &tx, &ch).await?;
215    tx.commit().await?;
216    Ok((ch, ratings))
217}
218
219async fn accept_locked(ctx: &Ctx, tx: &Transaction<'_>, ch: &mut ChallengeRow) -> ApiResult<()> {
220    tx.execute(&ctx.db.sql("UPDATE chess.challenges SET status = 'accepted' WHERE id = $1"), &[&ch.id])
221        .await?;
222    "accepted".clone_into(&mut ch.status);
223    play::start_game(ctx, tx, ch).await?;
224    ctx.metrics.challenges.with_label_values(&["accepted"]).inc();
225    Ok(())
226}
227
228async fn lock_open(ctx: &Ctx, tx: &Transaction<'_>, id: &str) -> ApiResult<ChallengeRow> {
229    let ch =
230        model::challenge(&ctx.db, tx, id, true).await?.ok_or_else(|| ApiError::not_found("challenge"))?;
231    let lapsed: bool = tx
232        .query_one(&ctx.db.sql("SELECT expires_at < now() FROM chess.challenges WHERE id = $1"), &[&id])
233        .await?
234        .get(0);
235    if ch.status != "created" || lapsed {
236        return Err(ApiError::not_found("challenge"));
237    }
238    Ok(ch)
239}
240
241pub async fn accept(ctx: &Ctx, who: &Identity, id: &str) -> ApiResult<()> {
242    let mut c = ctx.db.conn().await?;
243    let tx = c.transaction().await?;
244    let me = model::ensure_account(&ctx.db, &tx, who.player).await?;
245    let mut ch = lock_open(ctx, &tx, id).await?;
246    if ch.dest.player_id != me.player_id {
247        return Err(ApiError::not_found("challenge"));
248    }
249    can_play(ctx, &me)?;
250    can_play(ctx, &ch.challenger)?;
251    accept_locked(ctx, &tx, &mut ch).await?;
252    tx.commit().await?;
253    Ok(())
254}
255
256pub async fn decline(ctx: &Ctx, who: &Identity, id: &str, reason: Option<&str>) -> ApiResult<()> {
257    let mut c = ctx.db.conn().await?;
258    let tx = c.transaction().await?;
259    let mut ch = lock_open(ctx, &tx, id).await?;
260    if ch.dest.player_id != who.player {
261        return Err(ApiError::not_found("challenge"));
262    }
263    let key = reason
264        .map(str::to_lowercase)
265        .filter(|r| chess_proto::DECLINE_REASONS.iter().any(|(k, _)| k == r))
266        .unwrap_or_else(|| "generic".into());
267    tx.execute(
268        &ctx.db.sql("UPDATE chess.challenges SET status = 'declined', decline_reason = $2 WHERE id = $1"),
269        &[&ch.id, &key],
270    )
271    .await?;
272    "declined".clone_into(&mut ch.status);
273    ch.decline_reason = Some(key);
274    announce(ctx, &tx, &ch, ChallengeEvent::DECLINED, &[ch.challenger.player_id]).await?;
275    ctx.metrics.challenges.with_label_values(&["declined"]).inc();
276    tx.commit().await?;
277    Ok(())
278}
279
280pub async fn cancel(ctx: &Ctx, who: &Identity, id: &str) -> ApiResult<()> {
281    let mut c = ctx.db.conn().await?;
282    let tx = c.transaction().await?;
283    let mut ch = lock_open(ctx, &tx, id).await?;
284    if ch.challenger.player_id != who.player {
285        return Err(ApiError::not_found("challenge"));
286    }
287    tx.execute(&ctx.db.sql("UPDATE chess.challenges SET status = 'canceled' WHERE id = $1"), &[&ch.id])
288        .await?;
289    "canceled".clone_into(&mut ch.status);
290    announce(ctx, &tx, &ch, ChallengeEvent::CANCELED, &[ch.dest.player_id, ch.challenger.player_id]).await?;
291    ctx.metrics.challenges.with_label_values(&["canceled"]).inc();
292    tx.commit().await?;
293    Ok(())
294}
295
296/// Keep a challenge from lapsing while its challenger's stream is open.
297pub async fn keep_alive(ctx: &Ctx, id: &str) -> Result<()> {
298    let c = ctx.db.conn().await?;
299    c.execute(
300        &ctx.db.sql(
301            "UPDATE chess.challenges SET expires_at = now() + make_interval(secs => $2)
302              WHERE id = $1 AND status = 'created'",
303        ),
304        &[&id, &ctx.cfg.challenge_ttl().as_secs_f64()],
305    )
306    .await?;
307    Ok(())
308}
309
310/// The current status of a challenge.
311pub async fn status(ctx: &Ctx, id: &str) -> Result<Option<String>> {
312    let c = ctx.db.conn().await?;
313    Ok(c.query_opt(&ctx.db.sql("SELECT status FROM chess.challenges WHERE id = $1"), &[&id])
314        .await?
315        .map(|r| r.get(0)))
316}
317
318/// Pending challenges to and from a player: (in, out).
319pub async fn list(
320    ctx: &Ctx,
321    player: i64,
322) -> Result<(Vec<chess_proto::Challenge>, Vec<chess_proto::Challenge>)> {
323    let c = ctx.db.conn().await?;
324    let rows = c
325        .query(
326            &ctx.db.sql(&format!(
327                "{} WHERE c.status = 'created' AND c.expires_at > now() AND (c.dest_id = $1 OR c.challenger_id = $1)
328                 ORDER BY c.created_at",
329                model::CHALLENGE_SELECT
330            )),
331            &[&player],
332        )
333        .await?;
334    let (mut incoming, mut outgoing) = (Vec::new(), Vec::new());
335    for r in rows {
336        let ch = ChallengeRow::from_row(&r);
337        let ratings = model::challenge_ratings(&ctx.db, &*c, &ch).await?;
338        let j = ch.json(ctx.cfg.public_url(), Some(player), ratings);
339        if ch.dest.player_id == player { incoming.push(j) } else { outgoing.push(j) }
340    }
341    Ok((incoming, outgoing))
342}
343
344/// Lapse challenges past their time (sweeper).
345pub async fn expire(ctx: &Ctx) -> Result<usize> {
346    let mut c = ctx.db.conn().await?;
347    let tx = c.transaction().await?;
348    let ids: Vec<String> = tx
349        .query(
350            &ctx.db.sql(
351                "UPDATE chess.challenges SET status = 'canceled'
352                  WHERE id IN (SELECT id FROM chess.challenges WHERE status = 'created' AND expires_at < now()
353                               LIMIT 100 FOR UPDATE SKIP LOCKED)
354                 RETURNING id",
355            ),
356            &[],
357        )
358        .await?
359        .iter()
360        .map(|r| r.get(0))
361        .collect();
362    for id in &ids {
363        if let Some(ch) = model::challenge(&ctx.db, &tx, id, false).await? {
364            announce(ctx, &tx, &ch, ChallengeEvent::CANCELED, &[ch.dest.player_id, ch.challenger.player_id])
365                .await?;
366            ctx.metrics.challenges.with_label_values(&["lapsed"]).inc();
367        }
368    }
369    tx.commit().await?;
370    Ok(ids.len())
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn form(pairs: &[(&str, &str)]) -> HashMap<String, String> {
378        pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
379    }
380
381    #[test]
382    fn limits() {
383        for ok in [0, 15, 30, 45, 60, 90, 120, 180, 600, 10_800] {
384            assert!(valid_limit(ok), "{ok}");
385        }
386        for bad in [10, 75, 100, 10_860] {
387            assert!(!valid_limit(bad), "{bad}");
388        }
389    }
390
391    #[test]
392    fn parses_the_sites_challenge() {
393        // What a host site's Lichess backend sends.
394        let r = Request::parse(&form(&[
395            ("rated", "false"),
396            ("clock.limit", "180"),
397            ("clock.increment", "2"),
398            ("color", "random"),
399            ("variant", "standard"),
400            ("keepAliveStream", "false"),
401            ("acceptByToken", "esl_tok"),
402        ]))
403        .unwrap();
404        assert_eq!(r.clock, Some((180, 2)));
405        assert!(!r.rated && !r.keep_alive);
406        assert_eq!(r.accept_by_token.as_deref(), Some("esl_tok"));
407        assert_eq!(r.variant, "standard");
408    }
409
410    #[test]
411    fn rejects_what_we_dont_play() {
412        assert!(Request::parse(&form(&[("days", "3")])).is_err());
413        assert!(Request::parse(&form(&[("variant", "chess960")])).is_err());
414        assert!(Request::parse(&form(&[("clock.limit", "180")])).is_err());
415        assert!(Request::parse(&form(&[("clock.limit", "100"), ("clock.increment", "0")])).is_err());
416        assert!(Request::parse(&form(&[("clock.limit", "0"), ("clock.increment", "0")])).is_err());
417        assert!(Request::parse(&form(&[("rated", "true")])).is_err(), "unlimited can't be rated");
418        assert!(Request::parse(&form(&[("rules", "noAbort,bogus")])).is_err());
419        assert!(Request::parse(&form(&[("fen", "nonsense")])).is_err());
420    }
421
422    #[test]
423    fn from_position() {
424        let r = Request::parse(&form(&[("fen", "4k3/8/8/8/8/8/8/R3K3 w - - 0 1")])).unwrap();
425        assert_eq!(r.variant, "fromPosition");
426        assert!(r.clock.is_none());
427        let r = Request::parse(&form(&[("rules", "noAbort, noClaimWin")])).unwrap();
428        assert_eq!(r.rules, ["noAbort", "noClaimWin"]);
429    }
430}