1use std::collections::HashMap;
19use std::convert::Infallible;
20use std::time::Duration;
21
22use axum::Json;
23use axum::extract::{Path, Query, State};
24use axum::http::header;
25use axum::response::sse::{Event, KeepAlive, Sse};
26use axum::response::{IntoResponse, Response};
27use chess_core::{Game, Side, Speed, Status};
28use futures_util::stream;
29use serde::Deserialize;
30use serde_json::{Value, json};
31use tokio::sync::broadcast::error::RecvError;
32use tokio::sync::mpsc;
33use tokio_postgres::Transaction;
34
35use crate::app::{Ctx, Who};
36use crate::db::{Note, notify};
37use crate::error::{ApiError, ApiResult};
38use crate::model::{self, GameRow};
39use crate::play;
40
41#[derive(Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Start {
44 white: String,
45 black: String,
46 speed: String,
48 #[serde(default)]
49 rated: bool,
50 board: String,
52 #[serde(default)]
54 broadcast: bool,
55}
56
57struct Otb {
58 recorder: i64,
59 opponent: i64,
60 rated_requested: bool,
61 broadcast_key: Option<String>,
62}
63
64async fn otb(ctx: &Ctx, tx: &impl tokio_postgres::GenericClient, id: &str) -> anyhow::Result<Option<Otb>> {
65 Ok(tx
66 .query_opt(
67 &ctx.db.sql(
68 "SELECT recorder_id, opponent_id, rated_requested, broadcast_key FROM chess.otb_games WHERE game_id = $1",
69 ),
70 &[&id],
71 )
72 .await?
73 .map(|r| Otb { recorder: r.get(0), opponent: r.get(1), rated_requested: r.get(2), broadcast_key: r.get(3) }))
74}
75
76pub async fn start(State(ctx): State<Ctx>, who: Who, Json(b): Json<Start>) -> ApiResult<Response> {
79 let id = who.need(&["board:play"])?.clone();
80 let speed =
81 Speed::from_key(&b.speed).ok_or_else(|| ApiError::bad(format!("unknown speed {}", b.speed)))?;
82 if !matches!(b.board.as_str(), "chessnut" | "photos" | "manual") {
83 return Err(ApiError::bad("board is chessnut, photos or manual"));
84 }
85 let mut c = ctx.db.conn().await?;
86 let tx = c.transaction().await?;
87 let me = model::ensure_account(&ctx.db, &tx, id.player).await?;
88 let find = async |name: &str| model::account_by_name(&ctx.db, &tx, name).await;
89 let white = find(&b.white).await?.ok_or_else(|| ApiError::not_found(&format!("player {}", b.white)))?;
90 let black = find(&b.black).await?.ok_or_else(|| ApiError::not_found(&format!("player {}", b.black)))?;
91 if white.player_id == black.player_id {
92 return Err(ApiError::bad("white and black are the same player"));
93 }
94 let opponent = if white.player_id == me.player_id {
95 &black
96 } else if black.player_id == me.player_id {
97 &white
98 } else {
99 return Err(ApiError::bad("you record games you play in"));
100 };
101 if opponent.bot {
102 return Err(ApiError::bad("the computer isn't played on a real board here"));
103 }
104 let perf = speed.key();
105 let (wr, _, _) = model::rating(&ctx.db, &tx, white.player_id, perf).await?;
106 let (br, _, _) = model::rating(&ctx.db, &tx, black.player_id, perf).await?;
107 let game = Game::new(None).map_err(|e| anyhow::anyhow!("{e}"))?;
108 let game_id = model::new_id();
109 tx.execute(
110 &ctx.db.sql(
111 "INSERT INTO chess.games (id, white_id, black_id, variant, rated, source, speed, status, fen,
112 white_rating, black_rating, white_provisional, black_provisional, created_at, turn_started_at)
113 VALUES ($1, $2, $3, 'standard', false, 'otb', $4, 20, $5, $6, $7, $8, $9, clock_timestamp(), clock_timestamp())",
114 ),
115 &[
116 &game_id,
117 &white.player_id,
118 &black.player_id,
119 &perf,
120 &game.fen(),
121 &wr.int(),
122 &br.int(),
123 &wr.provisional(),
124 &br.provisional(),
125 ],
126 )
127 .await?;
128 let key = b.broadcast.then(|| crate::auth::random_token(24));
129 tx.execute(
130 &ctx.db.sql(
131 "INSERT INTO chess.otb_games (game_id, recorder_id, opponent_id, board, rated_requested, broadcast_key)
132 VALUES ($1, $2, $3, $4, $5, $6)",
133 ),
134 &[&game_id, &me.player_id, &opponent.player_id, &b.board, &b.rated, &key],
135 )
136 .await?;
137 notify(&tx, &ctx.db, &Note::Game(game_id.clone())).await?;
138 tx.commit().await?;
139 ctx.metrics.games_started.inc();
140 let base = ctx.cfg.public_url();
141 Ok(Json(json!({
142 "id": game_id,
143 "url": format!("{base}/{game_id}"),
144 "broadcastUrl": key.as_ref().map(|k| format!("{base}/broadcast/{game_id}?key={k}")),
145 }))
146 .into_response())
147}
148
149async fn recording(
151 ctx: &Ctx,
152 tx: &Transaction<'_>,
153 id: &str,
154 player: i64,
155) -> ApiResult<(GameRow, Otb, Game)> {
156 let o = otb(ctx, tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
157 if o.recorder != player {
158 return Err(ApiError::not_found("game"));
159 }
160 let row = model::game_for_update(&ctx.db, tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
161 if !row.status.is_ongoing() {
162 return Err(ApiError::conflict("the game is over"));
163 }
164 let game =
165 Game::from_moves(row.initial_fen.as_deref(), &row.moves).map_err(|e| anyhow::anyhow!("{e}"))?;
166 Ok((row, o, game))
167}
168
169#[derive(Deserialize)]
170pub struct Move {
171 uci: String,
172}
173
174pub async fn make_move(
176 State(ctx): State<Ctx>,
177 who: Who,
178 Path(id): Path<String>,
179 Json(m): Json<Move>,
180) -> ApiResult<Response> {
181 let mut c = ctx.db.conn().await?;
182 let tx = c.transaction().await?;
183 let (row, o, mut game) = recording(&ctx, &tx, &id, who.0.player).await?;
184 let played = game.play_uci(m.uci.trim()).map_err(|e| ApiError::bad(e.to_string()))?;
185 let mut move_ms = row.move_ms.clone();
186 let mut clock_ms = row.clock_ms.clone();
187 move_ms.push(row.elapsed_ms.max(0));
188 clock_ms.push(model::NO_CLOCK_MS);
189 let moves =
190 if row.moves.is_empty() { played.uci.clone() } else { format!("{} {}", row.moves, played.uci) };
191 tx.execute(
192 &ctx.db.sql(
193 "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, move_ms = $5, clock_ms = $6,
194 turn_started_at = clock_timestamp(), last_move_at = clock_timestamp()
195 WHERE id = $1",
196 ),
197 &[&id, &moves, &(row.ply + 1), &game.fen(), &move_ms, &clock_ms],
198 )
199 .await?;
200 if let Some(o2) = game.outcome() {
201 let row = model::game(&ctx.db, &tx, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
202 end_game(&ctx, &tx, &row, &o, o2.status, o2.winner).await?;
203 } else {
204 notify(&tx, &ctx.db, &Note::Game(id.clone())).await?;
205 }
206 tx.commit().await?;
207 Ok(Json(json!({"san": played.san, "fen": game.fen(), "status": game.outcome().map_or("started", |o| o.status.name())}))
208 .into_response())
209}
210
211#[derive(Deserialize)]
212pub struct Undo {
213 #[serde(default = "one")]
214 plies: usize,
215}
216
217fn one() -> usize {
218 1
219}
220
221pub async fn undo(
223 State(ctx): State<Ctx>,
224 who: Who,
225 Path(id): Path<String>,
226 Json(u): Json<Undo>,
227) -> ApiResult<Response> {
228 let mut c = ctx.db.conn().await?;
229 let tx = c.transaction().await?;
230 let (row, _, mut game) = recording(&ctx, &tx, &id, who.0.player).await?;
231 let n = u.plies.min(game.ply());
232 game.undo(n);
233 let keep = game.ply();
234 let moves = game.uci_moves().join(" ");
235 let move_ms: Vec<i64> = row.move_ms.iter().take(keep).copied().collect();
236 let clock_ms: Vec<i64> = row.clock_ms.iter().take(keep).copied().collect();
237 tx.execute(
238 &ctx.db.sql(
239 "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, move_ms = $5, clock_ms = $6,
240 turn_started_at = clock_timestamp()
241 WHERE id = $1",
242 ),
243 &[&id, &moves, &i32::try_from(keep).unwrap_or(0), &game.fen(), &move_ms, &clock_ms],
244 )
245 .await?;
246 notify(&tx, &ctx.db, &Note::Game(id.clone())).await?;
247 tx.commit().await?;
248 Ok(Json(json!({"fen": game.fen(), "ply": keep})).into_response())
249}
250
251#[derive(Deserialize)]
252pub struct End {
253 status: String,
255 #[serde(default)]
256 winner: Option<String>,
257}
258
259pub async fn end(
262 State(ctx): State<Ctx>,
263 who: Who,
264 Path(id): Path<String>,
265 Json(asked): Json<End>,
266) -> ApiResult<Response> {
267 let mut c = ctx.db.conn().await?;
268 let tx = c.transaction().await?;
269 let (row, rec, game) = recording(&ctx, &tx, &id, who.0.player).await?;
270 let winner = match asked.winner.as_deref() {
271 None | Some("") => None,
272 Some(w) => Some(Side::from_name(w).ok_or_else(|| ApiError::bad(format!("winner {w}?")))?),
273 };
274 let (status, winner) = match (Status::from_name(&asked.status), winner) {
275 (Some(s @ (Status::Resign | Status::Outoftime)), Some(w)) => {
276 if s == Status::Outoftime && game.cannot_win(w) { (s, None) } else { (s, Some(w)) }
277 }
278 (Some(Status::Draw), _) => (Status::Draw, None),
279 (Some(Status::Aborted), _) => (Status::Aborted, None),
280 _ => return Err(ApiError::bad("status is resign or outoftime (with a winner), draw or aborted")),
281 };
282 let pending = end_game(&ctx, &tx, &row, &rec, status, winner).await?;
283 tx.commit().await?;
284 Ok(Json(json!({"status": status.name(), "winner": winner.map(Side::name), "confirmation": if pending { "pending" } else { "none" }}))
285 .into_response())
286}
287
288async fn end_game(
291 ctx: &Ctx,
292 tx: &Transaction<'_>,
293 row: &GameRow,
294 o: &Otb,
295 status: Status,
296 winner: Option<Side>,
297) -> anyhow::Result<bool> {
298 play::finish(ctx, tx, row, status, winner).await?;
299 let waits = o.rated_requested && !status.is_void() && row.ply >= 2;
300 if waits {
301 tx.execute(&ctx.db.sql("UPDATE chess.games SET feed_at = NULL WHERE id = $1"), &[&row.id]).await?;
302 tx.execute(
303 &ctx.db.sql("UPDATE chess.otb_games SET confirm = 'pending' WHERE game_id = $1"),
304 &[&row.id],
305 )
306 .await?;
307 let recorder = row.player(if row.white.player_id == o.recorder { Side::White } else { Side::Black });
308 crate::push::notify(
309 ctx,
310 o.opponent,
311 "A result to confirm".into(),
312 format!("{} recorded your game on a real board. Confirm it in the app.", recorder.username),
313 Some(row.id.clone()),
314 None,
315 );
316 }
317 Ok(waits)
318}
319
320pub(crate) async fn confirm(ctx: &Ctx, player: i64, id: &str, accept: bool) -> ApiResult<Option<Value>> {
323 let mut c = ctx.db.conn().await?;
324 let tx = c.transaction().await?;
325 let pending = tx
326 .query_opt(
327 &ctx.db.sql(
328 "SELECT confirm FROM chess.otb_games WHERE game_id = $1 AND opponent_id = $2 FOR UPDATE",
329 ),
330 &[&id, &player],
331 )
332 .await?;
333 let Some(p) = pending else { return Ok(None) };
334 let state: String = p.get(0);
335 if state != "pending" {
336 return Err(ApiError::conflict(format!(
337 "already {}",
338 if state == "none" { "recorded" } else { &state }
339 )));
340 }
341 let row = model::game_for_update(&ctx.db, &tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
342 let diff = if accept {
343 let rated = GameRow { rated: true, ..row.clone() };
344 let (wd, bd) = play::rate(ctx, &tx, &rated, row.winner).await?;
345 tx.execute(
346 &ctx.db.sql(
347 "UPDATE chess.games SET rated = true, white_rating_diff = $2, black_rating_diff = $3, feed_at = clock_timestamp()
348 WHERE id = $1",
349 ),
350 &[&id, &wd, &bd],
351 )
352 .await?;
353 if row.white.player_id == player { wd } else { bd }
354 } else {
355 tx.execute(&ctx.db.sql("UPDATE chess.games SET feed_at = clock_timestamp() WHERE id = $1"), &[&id])
356 .await?;
357 None
358 };
359 tx.execute(
360 &ctx.db.sql("UPDATE chess.otb_games SET confirm = $2 WHERE game_id = $1"),
361 &[&id, &(if accept { "confirmed" } else { "declined" })],
362 )
363 .await?;
364 notify(&tx, &ctx.db, &Note::Game(id.to_owned())).await?;
365 tx.commit().await?;
366 Ok(Some(json!({"gameId": id, "status": if accept { "rated" } else { "declined" }, "ratingDiff": diff})))
367}
368
369pub(crate) async fn waiting_for(
371 ctx: &Ctx,
372 c: &impl tokio_postgres::GenericClient,
373 player: i64,
374) -> anyhow::Result<Vec<Value>> {
375 let rows = c
376 .query(
377 &ctx.db.sql(&format!(
378 "{} JOIN chess.otb_games o ON o.game_id = g.id
379 WHERE o.opponent_id = $1 AND o.confirm = 'pending' ORDER BY g.finished_at",
380 model::GAME_SELECT
381 )),
382 &[&player],
383 )
384 .await?;
385 Ok(rows
386 .iter()
387 .map(GameRow::from_row)
388 .map(|g| {
389 let recorder = if g.white.player_id == player { &g.black } else { &g.white };
390 json!({"id": g.id, "kind": "otb", "white": g.white.username, "black": g.black.username,
391 "winner": g.winner.map(Side::name), "status": g.status.name(), "speed": g.perf_key(),
392 "moves": g.ply, "uploadedBy": recorder.username, "createdAt": g.created_ms})
393 })
394 .collect())
395}
396
397fn view(row: &GameRow) -> Value {
399 let game = Game::from_moves(row.initial_fen.as_deref(), &row.moves).ok();
400 let sans: Vec<String> = game.as_ref().map(|g| g.sans().to_vec()).unwrap_or_default();
401 let first = sans.len().saturating_sub(10);
402 json!({
403 "id": row.id, "fen": row.fen, "lastMove": row.last_move(), "ply": row.ply,
404 "sans": &sans[first..], "firstPly": first,
405 "white": {"name": row.white.username, "rating": row.white_rating},
406 "black": {"name": row.black.username, "rating": row.black_rating},
407 "status": row.status.name(), "winner": row.winner.map(Side::name),
408 })
409}
410
411async fn broadcast_game(ctx: &Ctx, id: &str, key: Option<&String>) -> ApiResult<GameRow> {
413 let c = ctx.db.conn().await?;
414 let o = otb(ctx, &*c, id).await?.ok_or_else(|| ApiError::not_found("broadcast"))?;
415 let ok = match (o.broadcast_key.as_deref(), key) {
416 (Some(k), Some(given)) => crate::auth::hash_key(k) == crate::auth::hash_key(given),
417 _ => false,
418 };
419 if !ok {
420 return Err(ApiError::not_found("broadcast"));
421 }
422 model::game(&ctx.db, &*c, id).await?.ok_or_else(|| ApiError::not_found("broadcast"))
423}
424
425pub async fn events(
428 State(ctx): State<Ctx>,
429 Path(id): Path<String>,
430 Query(q): Query<HashMap<String, String>>,
431) -> ApiResult<Response> {
432 let row = broadcast_game(&ctx, &id, q.get("key")).await?;
433 let mut notes = ctx.db.subscribe();
434 let (tx, rx) = mpsc::channel::<Event>(64);
435 let first = Event::default().event("state").json_data(view(&row)).map_err(|e| anyhow::anyhow!("{e}"))?;
436 let _ = tx.send(first).await;
437 let ctx2 = ctx.clone();
438 tokio::spawn(async move {
439 loop {
440 match notes.recv().await {
441 Ok(Note::Game(g)) if g == id => {}
442 Ok(_) | Err(RecvError::Lagged(_)) => continue,
443 Err(RecvError::Closed) => return,
444 }
445 let Ok(c) = ctx2.db.conn().await else { continue };
446 let Ok(Some(row)) = model::game(&ctx2.db, &*c, &id).await else { continue };
447 let Ok(e) = Event::default().event("state").json_data(view(&row)) else { continue };
448 if tx.send(e).await.is_err() {
449 return;
450 }
451 }
452 });
453 let events =
454 stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|e| (Ok::<_, Infallible>(e), rx)) });
455 Ok(Sse::new(events).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response())
456}
457
458pub async fn page(
461 State(ctx): State<Ctx>,
462 Path(id): Path<String>,
463 Query(q): Query<HashMap<String, String>>,
464) -> ApiResult<Response> {
465 broadcast_game(&ctx, &id, q.get("key")).await?;
466 let html = OVERLAY.replace("{{base}}", &ctx.cfg.public_url().replace('"', ""));
467 Ok(([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], html)
468 .into_response())
469}
470
471const OVERLAY: &str = include_str!("overlay.html");