1use 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
23pub 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 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 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}