use std::collections::HashMap;
use futures_util::stream::BoxStream;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::api::gameplay::challenges::LichessChallenge;
use crate::api::gameplay::games::{LichessGameChatMessage, LichessGameStatusName};
use crate::client::LichessClient;
use crate::config::Host;
use crate::error::Result;
use crate::http;
use crate::model::{LichessColor, LichessSpeed, LichessTitle, LichessVariantKey};
#[derive(Debug, Serialize)]
pub(crate) struct ChatForm<'a> {
pub(crate) room: LichessChatRoom,
pub(crate) text: &'a str,
}
#[derive(Debug)]
pub struct BoardApi<'a> {
client: &'a LichessClient,
}
impl<'a> BoardApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
pub async fn stream_game(
&self,
game_id: &str,
) -> Result<BoxStream<'static, Result<LichessBoardEvent>>> {
let path = format!("/api/board/game/stream/{}", http::segment(game_id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn make_move(
&self,
game_id: &str,
chess_move: &str,
offering_draw: bool,
) -> Result<()> {
let path = format!(
"/api/board/game/{}/move/{}",
http::segment(game_id),
http::segment(chess_move)
);
let request = self
.client
.request(Method::POST, Host::Default, &path)
.query(&[("offeringDraw", offering_draw)]);
http::ok(request).await
}
pub async fn abort(&self, game_id: &str) -> Result<()> {
self.post_action(game_id, "abort").await
}
pub async fn resign(&self, game_id: &str) -> Result<()> {
self.post_action(game_id, "resign").await
}
pub async fn claim_victory(&self, game_id: &str) -> Result<()> {
self.post_action(game_id, "claim-victory").await
}
pub async fn claim_draw(&self, game_id: &str) -> Result<()> {
self.post_action(game_id, "claim-draw").await
}
pub async fn berserk(&self, game_id: &str) -> Result<()> {
self.post_action(game_id, "berserk").await
}
pub async fn handle_draw(&self, game_id: &str, accept: bool) -> Result<()> {
let path = format!(
"/api/board/game/{}/draw/{}",
http::segment(game_id),
yes_no(accept)
);
http::ok(self.client.request(Method::POST, Host::Default, &path)).await
}
pub async fn handle_takeback(&self, game_id: &str, accept: bool) -> Result<()> {
let path = format!(
"/api/board/game/{}/takeback/{}",
http::segment(game_id),
yes_no(accept)
);
http::ok(self.client.request(Method::POST, Host::Default, &path)).await
}
pub async fn write_chat(&self, game_id: &str, room: LichessChatRoom, text: &str) -> Result<()> {
let path = format!("/api/board/game/{}/chat", http::segment(game_id));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.form(&ChatForm { room, text });
http::ok(request).await
}
pub async fn stream_events(&self) -> Result<BoxStream<'static, Result<LichessIncomingEvent>>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/stream/event");
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn read_chat(&self, game_id: &str) -> Result<Vec<LichessGameChatMessage>> {
let path = format!("/api/board/game/{}/chat", http::segment(game_id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "Vec<LichessGameChatMessage>").await
}
#[must_use]
pub fn seek(&self) -> SeekRequest<'_> {
SeekRequest::new(self.client)
}
async fn post_action(&self, game_id: &str, action: &str) -> Result<()> {
let path = format!(
"/api/board/game/{}/{}",
http::segment(game_id),
http::segment(action)
);
http::ok(self.client.request(Method::POST, Host::Default, &path)).await
}
}
#[derive(Debug, Default, Serialize)]
struct SeekForm<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
rated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
time: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
increment: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
variant: Option<LichessVariantKey>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<&'a str>,
#[serde(rename = "ratingRange", skip_serializing_if = "Option::is_none")]
rating_range: Option<&'a str>,
}
#[derive(Debug)]
pub struct SeekRequest<'a> {
client: &'a LichessClient,
form: SeekForm<'a>,
}
impl<'a> SeekRequest<'a> {
fn new(client: &'a LichessClient) -> Self {
Self {
client,
form: SeekForm::default(),
}
}
#[must_use]
pub fn rated(mut self, rated: bool) -> Self {
self.form.rated = Some(rated);
self
}
#[must_use]
pub fn clock(mut self, time_minutes: f32, increment_secs: u32) -> Self {
self.form.time = Some(time_minutes);
self.form.increment = Some(increment_secs);
self
}
#[must_use]
pub fn days(mut self, days: u32) -> Self {
self.form.days = Some(days);
self
}
#[must_use]
pub fn variant(mut self, variant: LichessVariantKey) -> Self {
self.form.variant = Some(variant);
self
}
#[must_use]
pub fn color(mut self, color: &'a str) -> Self {
self.form.color = Some(color);
self
}
#[must_use]
pub fn rating_range(mut self, range: &'a str) -> Self {
self.form.rating_range = Some(range);
self
}
pub async fn send(self) -> Result<BoxStream<'static, Result<Value>>> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/board/seek")
.form(&self.form);
http::stream(request, self.client.max_line_bytes()).await
}
}
impl LichessClient {
#[must_use]
pub fn board(&self) -> BoardApi<'_> {
BoardApi::new(self)
}
}
pub(crate) fn yes_no(accept: bool) -> &'static str {
if accept { "yes" } else { "no" }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum LichessChatRoom {
Player,
Spectator,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessGameEventPlayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ai_level: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<LichessTitle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provisional: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessVariantInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<LichessVariantKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameEventClock {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub increment: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessGameExpiration {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idle_millis: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub millis_to_move: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameState {
pub moves: String,
pub wtime: i64,
pub btime: i64,
pub winc: i64,
pub binc: i64,
pub status: LichessGameStatusName,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub winner: Option<LichessColor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wdraw: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bdraw: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wtakeback: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub btakeback: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expiration: Option<LichessGameExpiration>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessGameFull {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<LichessVariantInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clock: Option<LichessGameEventClock>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub white: Option<LichessGameEventPlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub black: Option<LichessGameEventPlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_fen: Option<String>,
pub state: LichessGameState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub days_per_turn: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tournament_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessChatLine {
pub room: LichessChatRoom,
pub username: String,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessOpponentGone {
pub gone: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claim_win_in_seconds: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
#[non_exhaustive]
pub enum LichessBoardEvent {
#[serde(rename = "gameFull")]
GameFull(Box<LichessGameFull>),
#[serde(rename = "gameState")]
GameState(LichessGameState),
#[serde(rename = "chatLine")]
ChatLine(LichessChatLine),
#[serde(rename = "opponentGone")]
OpponentGone(LichessOpponentGone),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_game_full_with_nested_state() {
let json = r#"{"type":"gameFull","id":"g","rated":false,
"white":{"id":"a","name":"A","rating":1700},
"black":{"id":"b","name":"B","rating":1600},
"initialFen":"startpos",
"state":{"type":"gameState","moves":"e2e4","wtime":900000,"btime":900000,
"winc":0,"binc":0,"status":"started"}}"#;
let event: LichessBoardEvent = serde_json::from_str(json).unwrap();
match event {
LichessBoardEvent::GameFull(full) => {
assert_eq!(full.id, "g");
assert_eq!(full.state.moves, "e2e4");
}
other => panic!("expected gameFull, got {other:?}"),
}
}
#[test]
fn parses_game_state_and_chat_events() {
let state: LichessBoardEvent = serde_json::from_str(
r#"{"type":"gameState","moves":"e2e4 e7e5","wtime":1,"btime":2,
"winc":0,"binc":0,"status":"started"}"#,
)
.unwrap();
assert!(matches!(state, LichessBoardEvent::GameState(_)));
let chat: LichessBoardEvent = serde_json::from_str(
r#"{"type":"chatLine","room":"player","username":"a","text":"hi"}"#,
)
.unwrap();
match chat {
LichessBoardEvent::ChatLine(line) => assert_eq!(line.room, LichessChatRoom::Player),
other => panic!("expected chatLine, got {other:?}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessGameEventInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub game_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<LichessColor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_move: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<LichessSpeed>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_my_turn: Option<bool>,
#[serde(flatten)]
pub other: HashMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
#[non_exhaustive]
pub enum LichessIncomingEvent {
#[serde(rename = "gameStart")]
GameStart {
game: LichessGameEventInfo,
},
#[serde(rename = "gameFinish")]
GameFinish {
game: LichessGameEventInfo,
},
#[serde(rename = "challenge")]
Challenge {
challenge: Box<LichessChallenge>,
},
#[serde(rename = "challengeCanceled")]
ChallengeCanceled {
challenge: Box<LichessChallenge>,
},
#[serde(rename = "challengeDeclined")]
ChallengeDeclined {
challenge: Box<LichessChallenge>,
},
}
#[cfg(test)]
mod incoming_tests {
use super::*;
#[test]
fn parses_game_start_event() {
let json = r#"{"type":"gameStart","game":{"gameId":"g","color":"white","fen":"x"}}"#;
let event: LichessIncomingEvent = serde_json::from_str(json).unwrap();
match event {
LichessIncomingEvent::GameStart { game } => {
assert_eq!(game.game_id.as_deref(), Some("g"));
}
other => panic!("expected gameStart, got {other:?}"),
}
}
#[test]
fn parses_challenge_event() {
let json = r#"{"type":"challenge","challenge":{"id":"c","url":"u","status":"created"}}"#;
let event: LichessIncomingEvent = serde_json::from_str(json).unwrap();
assert!(matches!(event, LichessIncomingEvent::Challenge { .. }));
}
}