1use anyhow::Result;
11use chess_core::{Clock, Game, GameScore, Side, Status};
12use tokio_postgres::GenericClient;
13use tokio_postgres::Transaction;
14use tracing::{debug, warn};
15
16use crate::app::Ctx;
17use crate::db::{Note, notify};
18use crate::error::{ApiError, ApiResult};
19use crate::model::{self, Account, ChallengeRow, GameRow};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Api {
24 Board,
25 Bot,
26}
27
28impl Api {
29 pub fn check(self, a: &Account) -> ApiResult<()> {
30 match (self, a.bot) {
31 (Api::Board, true) => {
32 Err(ApiError::bad("This endpoint can only be used with a Board API (non-bot) account"))
33 }
34 (Api::Bot, false) => Err(ApiError::bad(
35 "This endpoint can only be used with a Bot account. See /api#tag/Bot/operation/botAccountUpgrade",
36 )),
37 _ => Ok(()),
38 }
39 }
40}
41
42fn side_str(s: Option<Side>) -> Option<&'static str> {
43 s.map(Side::name)
44}
45
46async fn lock_playing(
49 ctx: &Ctx,
50 tx: &Transaction<'_>,
51 id: &str,
52 player: i64,
53 api: Api,
54) -> ApiResult<(GameRow, Side)> {
55 let row = model::game_for_update(&ctx.db, tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
56 let side = row.side_of(player).ok_or_else(|| ApiError::not_found("game"))?;
57 api.check(row.player(side))?;
58 if !row.status.is_ongoing() {
59 return Err(ApiError::not_your_turn());
60 }
61 Ok((row, side))
62}
63
64fn replay(row: &GameRow) -> Result<Game> {
65 Game::from_moves(row.initial_fen.as_deref(), &row.moves)
66 .map_err(|e| anyhow::anyhow!("game {} doesn't replay: {e}", row.id))
67}
68
69pub async fn make_move(
70 ctx: &Ctx,
71 id: &str,
72 player: i64,
73 api: Api,
74 uci: &str,
75 offering_draw: bool,
76) -> ApiResult<()> {
77 let mut c = ctx.db.conn().await?;
78 let tx = c.transaction().await?;
79 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
80 let mut game = replay(&row)?;
81 if game.turn() != side {
82 return Err(ApiError::not_your_turn());
83 }
84 if let Some(end) = timed_out(ctx, &row, &game) {
86 finish(ctx, &tx, &row, end.0, end.1).await?;
87 tx.commit().await?;
88 return Err(ApiError::not_your_turn());
89 }
90 let mv = game.play_uci(uci).map_err(|e| ApiError::bad(e.to_string()))?;
91 let ply = row.ply as usize;
92 let mut move_ms = row.move_ms.clone();
93 let mut clock_ms = row.clock_ms.clone();
94 let (mut white_ms, mut black_ms) = (row.white_ms, row.black_ms);
95 if let (Some(init), Some(inc)) = (row.clock_initial_ms, row.clock_increment_ms) {
96 let mut clock = Clock {
97 initial_ms: init,
98 increment_ms: inc,
99 remaining_ms: [white_ms.unwrap_or(init), black_ms.unwrap_or(init)],
100 };
101 let used =
104 if ply >= 2 { clock.apply_move(side, row.elapsed_ms, ctx.cfg.lag_comp_ms).used_ms } else { 0 };
105 move_ms.push(used);
106 clock_ms.push(clock.remaining(side));
107 white_ms = Some(clock.remaining(Side::White));
108 black_ms = Some(clock.remaining(Side::Black));
109 } else {
110 move_ms.push(row.elapsed_ms.max(0));
111 clock_ms.push(model::NO_CLOCK_MS);
112 }
113 let opp_offered = row.draw_offer == Some(side.opposite());
116 let mut draw_offer = row.draw_offer.filter(|s| *s == side);
117 let mut agreed = false;
118 if offering_draw {
119 if opp_offered {
120 agreed = true;
121 } else if !(row.has_rule("noEarlyDraw") && ply < 60) {
122 draw_offer = Some(side);
123 }
124 }
125 let moves = if row.moves.is_empty() { mv.uci.clone() } else { format!("{} {}", row.moves, mv.uci) };
126 tx.execute(
127 &ctx.db.sql(
128 "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, white_ms = $5, black_ms = $6, move_ms = $7,
129 clock_ms = $8, draw_offer = $9, takeback_offer = NULL,
130 turn_started_at = clock_timestamp(), last_move_at = clock_timestamp()
131 WHERE id = $1",
132 ),
133 &[
134 &row.id,
135 &moves,
136 &(row.ply + 1),
137 &game.fen(),
138 &white_ms,
139 &black_ms,
140 &move_ms,
141 &clock_ms,
142 &side_str(draw_offer),
143 ],
144 )
145 .await?;
146 ctx.metrics.moves.inc();
147 let end = game.outcome().map(|o| (o.status, o.winner)).or(agreed.then_some((Status::Draw, None)));
148 if let Some((status, winner)) = end {
149 let row = model::game(&ctx.db, &tx, id).await?.ok_or_else(|| ApiError::not_found("game"))?;
150 finish(ctx, &tx, &row, status, winner).await?;
151 } else {
152 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
153 }
154 tx.commit().await?;
155 debug!(game = id, uci = mv.uci, "move");
156 Ok(())
157}
158
159fn timed_out(ctx: &Ctx, row: &GameRow, game: &Game) -> Option<(Status, Option<Side>)> {
162 if !row.status.is_ongoing() {
163 return None;
164 }
165 if row.ply < 2 {
166 let limit = i64::try_from(ctx.cfg.first_move_secs * 1000).unwrap_or(i64::MAX);
167 return (row.elapsed_ms > limit + ctx.cfg.lag_comp_ms).then_some((Status::Aborted, None));
168 }
169 if !row.clock_running() {
170 return None;
171 }
172 let turn = row.turn();
173 let left = match turn {
174 Side::White => row.white_ms,
175 Side::Black => row.black_ms,
176 }
177 .unwrap_or(0);
178 if row.elapsed_ms - ctx.cfg.lag_comp_ms < left {
179 return None;
180 }
181 let winner = turn.opposite();
182 Some(if game.cannot_win(winner) { (Status::Outoftime, None) } else { (Status::Outoftime, Some(winner)) })
184}
185
186pub async fn resign(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
187 let mut c = ctx.db.conn().await?;
188 let tx = c.transaction().await?;
189 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
190 if row.ply < 2 && !row.has_rule("noAbort") {
191 finish(ctx, &tx, &row, Status::Aborted, None).await?;
192 } else {
193 finish(ctx, &tx, &row, Status::Resign, Some(side.opposite())).await?;
194 }
195 tx.commit().await?;
196 Ok(())
197}
198
199pub async fn abort(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
200 let mut c = ctx.db.conn().await?;
201 let tx = c.transaction().await?;
202 let (row, _) = lock_playing(ctx, &tx, id, player, api).await?;
203 if row.ply >= 2 || row.has_rule("noAbort") {
204 return Err(ApiError::bad("This game can no longer be aborted"));
205 }
206 finish(ctx, &tx, &row, Status::Aborted, None).await?;
207 tx.commit().await?;
208 Ok(())
209}
210
211pub async fn draw(ctx: &Ctx, id: &str, player: i64, api: Api, accept: bool) -> ApiResult<()> {
212 let mut c = ctx.db.conn().await?;
213 let tx = c.transaction().await?;
214 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
215 let opp_offered = row.draw_offer == Some(side.opposite());
216 if accept && opp_offered {
217 finish(ctx, &tx, &row, Status::Draw, None).await?;
218 } else {
219 let offer = if accept {
220 if row.has_rule("noEarlyDraw") && row.ply < 60 {
221 return Err(ApiError::bad("Draw offers aren't allowed this early in the game"));
222 }
223 Some(side)
224 } else {
225 None
226 };
227 tx.execute(
228 &ctx.db.sql("UPDATE chess.games SET draw_offer = $2 WHERE id = $1"),
229 &[&row.id, &side_str(offer)],
230 )
231 .await?;
232 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
233 }
234 tx.commit().await?;
235 Ok(())
236}
237
238pub async fn takeback(ctx: &Ctx, id: &str, player: i64, api: Api, accept: bool) -> ApiResult<()> {
239 let mut c = ctx.db.conn().await?;
240 let tx = c.transaction().await?;
241 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
242 let opp = side.opposite();
243 if !accept {
244 if row.takeback_offer.is_some() {
245 tx.execute(&ctx.db.sql("UPDATE chess.games SET takeback_offer = NULL WHERE id = $1"), &[&row.id])
246 .await?;
247 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
248 }
249 } else if row.takeback_offer == Some(opp) {
250 let n = if row.turn() == opp { 2 } else { 1 };
252 if (row.ply as usize) < n {
253 return Err(ApiError::bad("Nothing to take back"));
254 }
255 let mut game = replay(&row)?;
256 game.undo(n);
257 let keep = game.ply();
258 let mut move_ms = row.move_ms.clone();
259 let mut clock_ms = row.clock_ms.clone();
260 move_ms.truncate(keep);
261 clock_ms.truncate(keep);
262 let (white_ms, black_ms) = restored_clocks(&row, game.first_to_move(), &clock_ms);
263 tx.execute(
264 &ctx.db.sql(
265 "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, move_ms = $5, clock_ms = $6,
266 white_ms = $7, black_ms = $8,
267 takeback_offer = NULL, draw_offer = NULL, turn_started_at = clock_timestamp()
268 WHERE id = $1",
269 ),
270 &[
271 &row.id,
272 &game.uci_moves().join(" "),
273 &(keep as i32),
274 &game.fen(),
275 &move_ms,
276 &clock_ms,
277 &white_ms,
278 &black_ms,
279 ],
280 )
281 .await?;
282 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
283 } else if row.takeback_offer != Some(side) {
284 if !row.has_moved(side) {
285 return Err(ApiError::bad("You haven't moved yet"));
286 }
287 tx.execute(
288 &ctx.db.sql("UPDATE chess.games SET takeback_offer = $2 WHERE id = $1"),
289 &[&row.id, &side.name()],
290 )
291 .await?;
292 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
293 }
294 tx.commit().await?;
295 Ok(())
296}
297
298fn restored_clocks(row: &GameRow, first: Side, clock_ms: &[i64]) -> (Option<i64>, Option<i64>) {
301 let Some(init) = row.clock_initial_ms else { return (row.white_ms, row.black_ms) };
302 let last = |side: Side| {
303 clock_ms
304 .iter()
305 .enumerate()
306 .rev()
307 .find(|(i, _)| Side::at_ply(first, *i) == side)
308 .map_or(init, |(_, ms)| *ms)
309 };
310 (Some(last(Side::White)), Some(last(Side::Black)))
311}
312
313pub async fn absent_secs(ctx: &Ctx, c: &impl GenericClient, row: &GameRow, player: i64) -> Result<i64> {
316 let r = c
317 .query_one(
318 &ctx.db.sql(
319 "SELECT extract(epoch from (clock_timestamp() - coalesce(
320 (SELECT seen_at FROM chess.presence WHERE game_id = $1 AND player_id = $2),
321 g.created_at)))::bigint
322 FROM chess.games g WHERE g.id = $1",
323 ),
324 &[&row.id, &player],
325 )
326 .await?;
327 Ok(r.get(0))
328}
329
330pub fn claim_in(ctx: &Ctx, absent: i64) -> Option<i64> {
332 let gone = i64::try_from(ctx.cfg.gone_after_secs).unwrap_or(i64::MAX);
333 let claim = i64::try_from(ctx.cfg.claim_win_secs).unwrap_or(i64::MAX);
334 (absent >= gone).then(|| (gone + claim - absent).max(0))
335}
336
337async fn claim(ctx: &Ctx, id: &str, player: i64, api: Api, victory: bool) -> ApiResult<()> {
338 let mut c = ctx.db.conn().await?;
339 let tx = c.transaction().await?;
340 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
341 if row.has_rule("noClaimWin") {
342 return Err(ApiError::bad("Claiming isn't allowed in this game"));
343 }
344 let opp = row.player(side.opposite()).player_id;
345 let absent = absent_secs(ctx, &tx, &row, opp).await?;
346 if claim_in(ctx, absent) != Some(0) {
347 return Err(ApiError::bad("Your opponent is still here"));
348 }
349 if victory {
350 finish(ctx, &tx, &row, Status::Timeout, Some(side)).await?;
351 } else {
352 finish(ctx, &tx, &row, Status::Draw, None).await?;
353 }
354 tx.commit().await?;
355 Ok(())
356}
357
358pub async fn claim_victory(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
359 claim(ctx, id, player, api, true).await
360}
361
362pub async fn claim_draw(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
363 claim(ctx, id, player, api, false).await
364}
365
366pub async fn touch(ctx: &Ctx, game: &str, player: i64) -> Result<()> {
368 let c = ctx.db.conn().await?;
369 c.execute(
370 &ctx.db.sql(
371 "INSERT INTO chess.presence (game_id, player_id, seen_at) VALUES ($1, $2, clock_timestamp())
372 ON CONFLICT (game_id, player_id) DO UPDATE SET seen_at = EXCLUDED.seen_at",
373 ),
374 &[&game, &player],
375 )
376 .await?;
377 Ok(())
378}
379
380pub async fn finish(
383 ctx: &Ctx,
384 tx: &Transaction<'_>,
385 row: &GameRow,
386 status: Status,
387 winner: Option<Side>,
388) -> Result<()> {
389 let humans = !row.white.bot || !row.black.bot;
390 let analysis =
391 if ctx.cfg.fairplay && humans && !status.is_void() && row.ply >= 20 { "pending" } else { "skipped" };
392 let (wd, bd) =
393 if row.rated && !status.is_void() { rate(ctx, tx, row, winner).await? } else { (None, None) };
394 let (mut wms, mut bms) = (row.white_ms, row.black_ms);
397 if row.clock_running() {
398 let left = if status == Status::Outoftime { 0 } else { row.live_ms(row.turn()) };
399 match row.turn() {
400 Side::White => wms = Some(left),
401 Side::Black => bms = Some(left),
402 }
403 }
404 tx.execute(
405 &ctx.db.sql(
406 "UPDATE chess.games SET status = $2, winner = $3, finished_at = clock_timestamp(), draw_offer = NULL,
407 takeback_offer = NULL, analysis = $4, white_rating_diff = $5, black_rating_diff = $6,
408 white_ms = $7, black_ms = $8
409 WHERE id = $1",
410 ),
411 &[&row.id, &status.id(), &side_str(winner), &analysis, &wd, &bd, &wms, &bms],
412 )
413 .await?;
414 ctx.metrics.games_finished.with_label_values(&[status.name()]).inc();
415 let done = model::game(&ctx.db, tx, &row.id).await?.unwrap_or_else(|| row.clone());
416 notify(tx, &ctx.db, &Note::Game(row.id.clone())).await?;
417 for p in [row.white.player_id, row.black.player_id] {
418 if let Some(e) = done.event(p, chess_proto::GameEvent::FINISH) {
419 notify(tx, &ctx.db, &Note::User { player: p, event: e }).await?;
420 }
421 }
422 debug!(game = row.id, status = status.name(), "game over");
423 Ok(())
424}
425
426async fn rate(
429 ctx: &Ctx,
430 tx: &Transaction<'_>,
431 row: &GameRow,
432 winner: Option<Side>,
433) -> Result<(Option<i32>, Option<i32>)> {
434 let perf = row.perf_key();
435 let mut ids = [row.white.player_id, row.black.player_id];
436 ids.sort_unstable();
437 for id in ids {
438 tx.execute(
439 &ctx.db.sql(
440 "INSERT INTO chess.ratings (player_id, perf, rating, deviation, volatility)
441 VALUES ($1, $2, 1500, 500, 0.09) ON CONFLICT DO NOTHING",
442 ),
443 &[&id, &perf],
444 )
445 .await?;
446 tx.execute(
447 &ctx.db.sql("SELECT 1 FROM chess.ratings WHERE player_id = $1 AND perf = $2 FOR UPDATE"),
448 &[&id, &perf],
449 )
450 .await?;
451 }
452 let (w, _, _) = model::rating(&ctx.db, tx, row.white.player_id, perf).await?;
453 let (b, _, _) = model::rating(&ctx.db, tx, row.black.player_id, perf).await?;
454 let (sw, sb) = match winner {
455 Some(Side::White) => (GameScore::Win, GameScore::Loss),
456 Some(Side::Black) => (GameScore::Loss, GameScore::Win),
457 None => (GameScore::Draw, GameScore::Draw),
458 };
459 let nw = w.after_game(&b, sw);
460 let nb = b.after_game(&w, sb);
461 let mut diffs = [0; 2];
462 for (i, (id, old, new)) in
463 [(row.white.player_id, w, nw), (row.black.player_id, b, nb)].into_iter().enumerate()
464 {
465 let diff = new.int() - old.int();
466 diffs[i] = diff;
467 tx.execute(
468 &ctx.db.sql(
469 "UPDATE chess.ratings SET rating = $3, deviation = $4, volatility = $5, games = games + 1,
470 prog = $6, updated_at = now()
471 WHERE player_id = $1 AND perf = $2",
472 ),
473 &[&id, &perf, &new.rating, &new.deviation, &new.volatility, &diff],
474 )
475 .await?;
476 }
477 Ok((Some(diffs[0]), Some(diffs[1])))
478}
479
480pub async fn start_game(ctx: &Ctx, tx: &Transaction<'_>, ch: &ChallengeRow) -> Result<GameRow> {
482 let (white, black) = match ch.final_color {
483 Side::White => (&ch.challenger, &ch.dest),
484 Side::Black => (&ch.dest, &ch.challenger),
485 };
486 let speed = ch.speed();
487 let (wr, _, _) = model::rating(&ctx.db, tx, white.player_id, speed.key()).await?;
488 let (br, _, _) = model::rating(&ctx.db, tx, black.player_id, speed.key()).await?;
489 let game = Game::new(ch.initial_fen.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
490 let init = ch.clock_limit.map(|l| i64::from(l) * 1000);
491 let inc = ch.clock_increment.map(|i| i64::from(i) * 1000);
492 tx.execute(
493 &ctx.db.sql(
494 "INSERT INTO chess.games (id, white_id, black_id, variant, initial_fen, rated, source, speed, rules,
495 clock_initial_ms, clock_increment_ms, status, fen, white_ms, black_ms, white_rating, black_rating,
496 white_provisional, black_provisional, created_at, turn_started_at)
497 VALUES ($1, $2, $3, $4, $5, $6, 'friend', $7, $8, $9, $10, 20, $11, $9, $9, $12, $13, $14, $15,
498 clock_timestamp(), clock_timestamp())",
499 ),
500 &[
501 &ch.id,
502 &white.player_id,
503 &black.player_id,
504 &ch.variant,
505 &game.initial_fen(),
506 &ch.rated,
507 &speed.key(),
508 &ch.rules,
509 &init,
510 &inc,
511 &game.fen(),
512 &wr.int(),
513 &br.int(),
514 &wr.provisional(),
515 &br.provisional(),
516 ],
517 )
518 .await?;
519 let row = model::game(&ctx.db, tx, &ch.id).await?.ok_or_else(|| anyhow::anyhow!("new game vanished"))?;
520 for p in [white.player_id, black.player_id] {
521 if let Some(e) = row.event(p, chess_proto::GameEvent::START) {
522 notify(tx, &ctx.db, &Note::User { player: p, event: e }).await?;
523 }
524 }
525 notify(tx, &ctx.db, &Note::Game(row.id.clone())).await?;
526 ctx.metrics.games_started.inc();
527 Ok(row)
528}
529
530pub async fn sweep(ctx: &Ctx) -> Result<usize> {
534 let mut done = 0;
535 done += crate::challenges::expire(ctx).await?;
536 let c = ctx.db.conn().await?;
537 let ids: Vec<String> = c
538 .query(
539 &ctx.db.sql(
540 "SELECT id FROM chess.games
541 WHERE status = 20 AND (
542 (ply < 2 AND clock_timestamp() > turn_started_at + make_interval(secs => ($1::float8 + $2::float8) / 1000.0))
543 OR (ply >= 2 AND clock_initial_ms IS NOT NULL
544 AND clock_timestamp() > turn_started_at
545 + make_interval(secs => ((CASE WHEN fen LIKE '% w %' THEN white_ms ELSE black_ms END) + $2::float8) / 1000.0)))
546 LIMIT 100",
547 ),
548 &[&(ctx.cfg.first_move_secs as f64 * 1000.0), &(ctx.cfg.lag_comp_ms as f64)],
549 )
550 .await?
551 .iter()
552 .map(|r| r.get(0))
553 .collect();
554 drop(c);
555 for id in ids {
556 let mut c = ctx.db.conn().await?;
557 let tx = c.transaction().await?;
558 let row = tx
559 .query_opt(
560 &ctx.db.sql(&format!("{} WHERE g.id = $1 FOR UPDATE OF g SKIP LOCKED", model::GAME_SELECT)),
561 &[&id],
562 )
563 .await?
564 .map(|r| GameRow::from_row(&r));
565 let Some(row) = row else { continue };
566 let game = match replay(&row) {
567 Ok(g) => g,
568 Err(e) => {
569 warn!("{e:#}");
570 continue;
571 }
572 };
573 if let Some((status, winner)) = timed_out(ctx, &row, &game) {
574 finish(ctx, &tx, &row, status, winner).await?;
575 done += 1;
576 }
577 tx.commit().await?;
578 }
579 Ok(done)
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn api_kind_checks() {
588 let human = Account {
589 player_id: 1,
590 username: "a".into(),
591 bot: false,
592 marked: false,
593 created_ms: 0,
594 seen_ms: None,
595 };
596 let bot = Account { bot: true, ..human.clone() };
597 assert!(Api::Board.check(&human).is_ok());
598 assert!(Api::Bot.check(&bot).is_ok());
599 assert!(Api::Board.check(&bot).is_err());
600 assert!(Api::Bot.check(&human).is_err());
601 }
602}