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