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 if row.days_per_turn.is_some() && end.is_none() {
157 let me = &row.player(side).username;
158 let them = row.player(side.opposite()).player_id;
159 crate::push::notify(
160 ctx,
161 them,
162 "Your move".into(),
163 format!("{me} played {}.", mv.san),
164 Some(row.id.clone()),
165 Some(row.id.clone()),
166 );
167 }
168 debug!(game = id, uci = mv.uci, "move");
169 Ok(())
170}
171
172fn timed_out(ctx: &Ctx, row: &GameRow, game: &Game) -> Option<(Status, Option<Side>)> {
175 if !row.status.is_ongoing() || row.source == "otb" {
178 return None;
179 }
180 if let Some(left) = row.days_left_ms() {
181 if left > 0 {
183 return None;
184 }
185 if row.ply < 2 {
186 return Some((Status::Aborted, None));
187 }
188 let winner = row.turn().opposite();
189 return Some(if game.cannot_win(winner) {
190 (Status::Outoftime, None)
191 } else {
192 (Status::Outoftime, Some(winner))
193 });
194 }
195 if row.ply < 2 {
196 let limit = i64::try_from(ctx.cfg.first_move_secs * 1000).unwrap_or(i64::MAX);
197 return (row.elapsed_ms > limit + ctx.cfg.lag_comp_ms).then_some((Status::Aborted, None));
198 }
199 if !row.clock_running() {
200 return None;
201 }
202 let turn = row.turn();
203 let left = match turn {
204 Side::White => row.white_ms,
205 Side::Black => row.black_ms,
206 }
207 .unwrap_or(0);
208 if row.elapsed_ms - ctx.cfg.lag_comp_ms < left {
209 return None;
210 }
211 let winner = turn.opposite();
212 Some(if game.cannot_win(winner) { (Status::Outoftime, None) } else { (Status::Outoftime, Some(winner)) })
214}
215
216pub async fn resign(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
217 let mut c = ctx.db.conn().await?;
218 let tx = c.transaction().await?;
219 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
220 if row.ply < 2 && !row.has_rule("noAbort") {
221 finish(ctx, &tx, &row, Status::Aborted, None).await?;
222 } else {
223 finish(ctx, &tx, &row, Status::Resign, Some(side.opposite())).await?;
224 }
225 tx.commit().await?;
226 Ok(())
227}
228
229pub async fn abort(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
230 let mut c = ctx.db.conn().await?;
231 let tx = c.transaction().await?;
232 let (row, _) = lock_playing(ctx, &tx, id, player, api).await?;
233 if row.ply >= 2 || row.has_rule("noAbort") {
234 return Err(ApiError::bad("This game can no longer be aborted"));
235 }
236 finish(ctx, &tx, &row, Status::Aborted, None).await?;
237 tx.commit().await?;
238 Ok(())
239}
240
241pub async fn draw(ctx: &Ctx, id: &str, player: i64, api: Api, accept: bool) -> ApiResult<()> {
242 let mut c = ctx.db.conn().await?;
243 let tx = c.transaction().await?;
244 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
245 let opp_offered = row.draw_offer == Some(side.opposite());
246 if accept && opp_offered {
247 finish(ctx, &tx, &row, Status::Draw, None).await?;
248 } else {
249 let offer = if accept {
250 if row.has_rule("noEarlyDraw") && row.ply < 60 {
251 return Err(ApiError::bad("Draw offers aren't allowed this early in the game"));
252 }
253 Some(side)
254 } else {
255 None
256 };
257 tx.execute(
258 &ctx.db.sql("UPDATE chess.games SET draw_offer = $2 WHERE id = $1"),
259 &[&row.id, &side_str(offer)],
260 )
261 .await?;
262 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
263 }
264 tx.commit().await?;
265 Ok(())
266}
267
268pub async fn takeback(ctx: &Ctx, id: &str, player: i64, api: Api, accept: bool) -> ApiResult<()> {
269 let mut c = ctx.db.conn().await?;
270 let tx = c.transaction().await?;
271 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
272 let opp = side.opposite();
273 if !accept {
274 if row.takeback_offer.is_some() {
275 tx.execute(&ctx.db.sql("UPDATE chess.games SET takeback_offer = NULL WHERE id = $1"), &[&row.id])
276 .await?;
277 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
278 }
279 } else if row.takeback_offer == Some(opp) {
280 let n = if row.turn() == opp { 2 } else { 1 };
282 if (row.ply as usize) < n {
283 return Err(ApiError::bad("Nothing to take back"));
284 }
285 let mut game = replay(&row)?;
286 game.undo(n);
287 let keep = game.ply();
288 let mut move_ms = row.move_ms.clone();
289 let mut clock_ms = row.clock_ms.clone();
290 move_ms.truncate(keep);
291 clock_ms.truncate(keep);
292 let (white_ms, black_ms) = restored_clocks(&row, game.first_to_move(), &clock_ms);
293 tx.execute(
294 &ctx.db.sql(
295 "UPDATE chess.games SET moves = $2, ply = $3, fen = $4, move_ms = $5, clock_ms = $6,
296 white_ms = $7, black_ms = $8,
297 takeback_offer = NULL, draw_offer = NULL, turn_started_at = clock_timestamp()
298 WHERE id = $1",
299 ),
300 &[
301 &row.id,
302 &game.uci_moves().join(" "),
303 &(keep as i32),
304 &game.fen(),
305 &move_ms,
306 &clock_ms,
307 &white_ms,
308 &black_ms,
309 ],
310 )
311 .await?;
312 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
313 } else if row.takeback_offer != Some(side) {
314 if !row.has_moved(side) {
315 return Err(ApiError::bad("You haven't moved yet"));
316 }
317 tx.execute(
318 &ctx.db.sql("UPDATE chess.games SET takeback_offer = $2 WHERE id = $1"),
319 &[&row.id, &side.name()],
320 )
321 .await?;
322 notify(&tx, &ctx.db, &Note::Game(row.id.clone())).await?;
323 }
324 tx.commit().await?;
325 Ok(())
326}
327
328fn restored_clocks(row: &GameRow, first: Side, clock_ms: &[i64]) -> (Option<i64>, Option<i64>) {
331 let Some(init) = row.clock_initial_ms else { return (row.white_ms, row.black_ms) };
332 let last = |side: Side| {
333 clock_ms
334 .iter()
335 .enumerate()
336 .rev()
337 .find(|(i, _)| Side::at_ply(first, *i) == side)
338 .map_or(init, |(_, ms)| *ms)
339 };
340 (Some(last(Side::White)), Some(last(Side::Black)))
341}
342
343pub async fn absent_secs(ctx: &Ctx, c: &impl GenericClient, row: &GameRow, player: i64) -> Result<i64> {
346 let r = c
347 .query_one(
348 &ctx.db.sql(
349 "SELECT extract(epoch from (clock_timestamp() - coalesce(
350 (SELECT seen_at FROM chess.presence WHERE game_id = $1 AND player_id = $2),
351 g.created_at)))::bigint
352 FROM chess.games g WHERE g.id = $1",
353 ),
354 &[&row.id, &player],
355 )
356 .await?;
357 Ok(r.get(0))
358}
359
360pub fn claim_in(ctx: &Ctx, absent: i64) -> Option<i64> {
362 let gone = i64::try_from(ctx.cfg.gone_after_secs).unwrap_or(i64::MAX);
363 let claim = i64::try_from(ctx.cfg.claim_win_secs).unwrap_or(i64::MAX);
364 (absent >= gone).then(|| (gone + claim - absent).max(0))
365}
366
367async fn claim(ctx: &Ctx, id: &str, player: i64, api: Api, victory: bool) -> ApiResult<()> {
368 let mut c = ctx.db.conn().await?;
369 let tx = c.transaction().await?;
370 let (row, side) = lock_playing(ctx, &tx, id, player, api).await?;
371 if row.has_rule("noClaimWin") {
372 return Err(ApiError::bad("Claiming isn't allowed in this game"));
373 }
374 let opp = row.player(side.opposite()).player_id;
375 let absent = absent_secs(ctx, &tx, &row, opp).await?;
376 if claim_in(ctx, absent) != Some(0) {
377 return Err(ApiError::bad("Your opponent is still here"));
378 }
379 if victory {
380 finish(ctx, &tx, &row, Status::Timeout, Some(side)).await?;
381 } else {
382 finish(ctx, &tx, &row, Status::Draw, None).await?;
383 }
384 tx.commit().await?;
385 Ok(())
386}
387
388pub async fn claim_victory(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
389 claim(ctx, id, player, api, true).await
390}
391
392pub async fn claim_draw(ctx: &Ctx, id: &str, player: i64, api: Api) -> ApiResult<()> {
393 claim(ctx, id, player, api, false).await
394}
395
396pub async fn touch(ctx: &Ctx, game: &str, player: i64) -> Result<()> {
398 let c = ctx.db.conn().await?;
399 c.execute(
400 &ctx.db.sql(
401 "INSERT INTO chess.presence (game_id, player_id, seen_at) VALUES ($1, $2, clock_timestamp())
402 ON CONFLICT (game_id, player_id) DO UPDATE SET seen_at = EXCLUDED.seen_at",
403 ),
404 &[&game, &player],
405 )
406 .await?;
407 Ok(())
408}
409
410pub async fn finish(
413 ctx: &Ctx,
414 tx: &Transaction<'_>,
415 row: &GameRow,
416 status: Status,
417 winner: Option<Side>,
418) -> Result<()> {
419 let humans = !row.white.bot || !row.black.bot;
420 let analysis =
421 if ctx.cfg.fairplay && humans && !status.is_void() && row.ply >= 20 { "pending" } else { "skipped" };
422 let (wd, bd) =
423 if row.rated && !status.is_void() { rate(ctx, tx, row, winner).await? } else { (None, None) };
424 let (mut wms, mut bms) = (row.white_ms, row.black_ms);
427 if row.clock_running() {
428 let left = if status == Status::Outoftime { 0 } else { row.live_ms(row.turn()) };
429 match row.turn() {
430 Side::White => wms = Some(left),
431 Side::Black => bms = Some(left),
432 }
433 }
434 tx.execute(
435 &ctx.db.sql(
436 "UPDATE chess.games SET status = $2, winner = $3, finished_at = clock_timestamp(), feed_at = clock_timestamp(), draw_offer = NULL,
437 takeback_offer = NULL, analysis = $4, white_rating_diff = $5, black_rating_diff = $6,
438 white_ms = $7, black_ms = $8
439 WHERE id = $1",
440 ),
441 &[&row.id, &status.id(), &side_str(winner), &analysis, &wd, &bd, &wms, &bms],
442 )
443 .await?;
444 ctx.metrics.games_finished.with_label_values(&[status.name()]).inc();
445 let done = model::game(&ctx.db, tx, &row.id).await?.unwrap_or_else(|| row.clone());
446 notify(tx, &ctx.db, &Note::Game(row.id.clone())).await?;
447 for p in [row.white.player_id, row.black.player_id] {
448 if let Some(e) = done.event(p, chess_proto::GameEvent::FINISH) {
449 notify(tx, &ctx.db, &Note::User { player: p, event: e }).await?;
450 }
451 }
452 debug!(game = row.id, status = status.name(), "game over");
453 Ok(())
454}
455
456pub(crate) async fn rate(
459 ctx: &Ctx,
460 tx: &Transaction<'_>,
461 row: &GameRow,
462 winner: Option<Side>,
463) -> Result<(Option<i32>, Option<i32>)> {
464 let perf = row.perf_key();
465 let mut ids = [row.white.player_id, row.black.player_id];
466 ids.sort_unstable();
467 for id in ids {
468 tx.execute(
469 &ctx.db.sql(
470 "INSERT INTO chess.ratings (player_id, perf, rating, deviation, volatility)
471 VALUES ($1, $2, 1500, 500, 0.09) ON CONFLICT DO NOTHING",
472 ),
473 &[&id, &perf],
474 )
475 .await?;
476 tx.execute(
477 &ctx.db.sql("SELECT 1 FROM chess.ratings WHERE player_id = $1 AND perf = $2 FOR UPDATE"),
478 &[&id, &perf],
479 )
480 .await?;
481 }
482 let (w, _, _) = model::rating(&ctx.db, tx, row.white.player_id, perf).await?;
483 let (b, _, _) = model::rating(&ctx.db, tx, row.black.player_id, perf).await?;
484 let (sw, sb) = match winner {
485 Some(Side::White) => (GameScore::Win, GameScore::Loss),
486 Some(Side::Black) => (GameScore::Loss, GameScore::Win),
487 None => (GameScore::Draw, GameScore::Draw),
488 };
489 let nw = w.after_game(&b, sw);
490 let nb = b.after_game(&w, sb);
491 let mut diffs = [0; 2];
492 for (i, (account, old, new)) in [(&row.white, w, nw), (&row.black, b, nb)].into_iter().enumerate() {
493 if crate::ai::level_of(&account.username).is_some() {
495 continue;
496 }
497 let id = account.player_id;
498 let diff = new.int() - old.int();
499 diffs[i] = diff;
500 tx.execute(
501 &ctx.db.sql(
502 "UPDATE chess.ratings SET rating = $3, deviation = $4, volatility = $5, games = games + 1,
503 prog = $6, updated_at = now()
504 WHERE player_id = $1 AND perf = $2",
505 ),
506 &[&id, &perf, &new.rating, &new.deviation, &new.volatility, &diff],
507 )
508 .await?;
509 }
510 Ok((Some(diffs[0]), Some(diffs[1])))
511}
512
513pub async fn start_game(ctx: &Ctx, tx: &Transaction<'_>, ch: &ChallengeRow) -> Result<GameRow> {
515 let (white, black) = match ch.final_color {
516 Side::White => (&ch.challenger, &ch.dest),
517 Side::Black => (&ch.dest, &ch.challenger),
518 };
519 let speed = ch.speed();
520 let (wr, _, _) = model::rating(&ctx.db, tx, white.player_id, speed.key()).await?;
521 let (br, _, _) = model::rating(&ctx.db, tx, black.player_id, speed.key()).await?;
522 let game = Game::new(ch.initial_fen.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
523 let init = ch.clock_limit.map(|l| i64::from(l) * 1000);
524 let inc = ch.clock_increment.map(|i| i64::from(i) * 1000);
525 tx.execute(
526 &ctx.db.sql(
527 "INSERT INTO chess.games (id, white_id, black_id, variant, initial_fen, rated, source, speed, rules,
528 clock_initial_ms, clock_increment_ms, status, fen, white_ms, black_ms, white_rating, black_rating,
529 white_provisional, black_provisional, created_at, turn_started_at, days_per_turn)
530 VALUES ($1, $2, $3, $4, $5, $6, 'friend', $7, $8, $9, $10, 20, $11, $9, $9, $12, $13, $14, $15,
531 clock_timestamp(), clock_timestamp(), $16)",
532 ),
533 &[
534 &ch.id,
535 &white.player_id,
536 &black.player_id,
537 &ch.variant,
538 &game.initial_fen(),
539 &ch.rated,
540 &speed.key(),
541 &ch.rules,
542 &init,
543 &inc,
544 &game.fen(),
545 &wr.int(),
546 &br.int(),
547 &wr.provisional(),
548 &br.provisional(),
549 &ch.days,
550 ],
551 )
552 .await?;
553 let row = model::game(&ctx.db, tx, &ch.id).await?.ok_or_else(|| anyhow::anyhow!("new game vanished"))?;
554 for p in [white.player_id, black.player_id] {
555 if let Some(e) = row.event(p, chess_proto::GameEvent::START) {
556 notify(tx, &ctx.db, &Note::User { player: p, event: e }).await?;
557 }
558 }
559 notify(tx, &ctx.db, &Note::Game(row.id.clone())).await?;
560 ctx.metrics.games_started.inc();
561 Ok(row)
562}
563
564pub async fn sweep(ctx: &Ctx) -> Result<usize> {
568 let mut done = 0;
569 done += crate::challenges::expire(ctx).await?;
570 let c = ctx.db.conn().await?;
571 let ids: Vec<String> = c
572 .query(
573 &ctx.db.sql(
574 "SELECT id FROM chess.games
575 WHERE status = 20 AND source <> 'otb' AND (
576 (days_per_turn IS NOT NULL AND clock_timestamp() > turn_started_at + make_interval(days => days_per_turn))
577 OR (days_per_turn IS NULL AND ply < 2
578 AND clock_timestamp() > turn_started_at + make_interval(secs => ($1::float8 + $2::float8) / 1000.0))
579 OR (ply >= 2 AND clock_initial_ms IS NOT NULL
580 AND clock_timestamp() > turn_started_at
581 + make_interval(secs => ((CASE WHEN fen LIKE '% w %' THEN white_ms ELSE black_ms END) + $2::float8) / 1000.0)))
582 LIMIT 100",
583 ),
584 &[&(ctx.cfg.first_move_secs as f64 * 1000.0), &(ctx.cfg.lag_comp_ms as f64)],
585 )
586 .await?
587 .iter()
588 .map(|r| r.get(0))
589 .collect();
590 drop(c);
591 for id in ids {
592 let mut c = ctx.db.conn().await?;
593 let tx = c.transaction().await?;
594 let row = tx
595 .query_opt(
596 &ctx.db.sql(&format!("{} WHERE g.id = $1 FOR UPDATE OF g SKIP LOCKED", model::GAME_SELECT)),
597 &[&id],
598 )
599 .await?
600 .map(|r| GameRow::from_row(&r));
601 let Some(row) = row else { continue };
602 let game = match replay(&row) {
603 Ok(g) => g,
604 Err(e) => {
605 warn!("{e:#}");
606 continue;
607 }
608 };
609 if let Some((status, winner)) = timed_out(ctx, &row, &game) {
610 finish(ctx, &tx, &row, status, winner).await?;
611 done += 1;
612 }
613 tx.commit().await?;
614 }
615 Ok(done)
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 #[test]
623 fn api_kind_checks() {
624 let human = Account {
625 player_id: 1,
626 username: "a".into(),
627 bot: false,
628 marked: false,
629 created_ms: 0,
630 seen_ms: None,
631 };
632 let bot = Account { bot: true, ..human.clone() };
633 assert!(Api::Board.check(&human).is_ok());
634 assert!(Api::Bot.check(&bot).is_ok());
635 assert!(Api::Board.check(&bot).is_err());
636 assert!(Api::Bot.check(&human).is_err());
637 }
638}