1use std::collections::HashSet;
11use std::time::Duration;
12
13use axum::extract::{MatchedPath, Path, State};
14use axum::response::Response;
15use bytes::Bytes;
16use chess_core::{Game, Side};
17use chess_proto as p;
18use serde_json::json;
19use tokio::sync::broadcast::error::RecvError;
20use tokio::sync::mpsc;
21use tracing::{debug, warn};
22
23use crate::app::{Ctx, Who};
24use crate::db::Note;
25use crate::error::{ApiError, ApiResult};
26use crate::http::{line, ndjson};
27use crate::metrics::StreamGuard;
28use crate::model::{self, GameRow};
29use crate::play::{self, Api};
30
31const KEEPALIVE: Duration = Duration::from_secs(6);
32
33pub async fn events(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
36 let id = who.need(&["challenge:read", "bot:play", "board:play"])?.clone();
37 let c = ctx.db.conn().await?;
38 model::ensure_account(&ctx.db, &*c, id.player).await?;
39 drop(c);
40 let slot = ctx.slot(id.player, "event")?;
41 let (tx, rx) = mpsc::channel::<Bytes>(64);
42 tokio::spawn(async move {
43 event_stream(ctx, id.player, tx).await;
44 drop(slot);
45 });
46 Ok(ndjson(rx))
47}
48
49#[derive(Default)]
52struct Sent {
53 games: HashSet<String>,
54 challenges: HashSet<String>,
55 ended: HashSet<String>,
58}
59
60impl Sent {
61 fn note(&mut self, event: &serde_json::Value) -> bool {
63 let kind = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
64 let game = event.pointer("/game/id").and_then(|v| v.as_str()).map(str::to_owned);
65 let challenge = event.pointer("/challenge/id").and_then(|v| v.as_str()).map(str::to_owned);
66 match (kind, game, challenge) {
67 (p::GameEvent::START, Some(g), _) => self.games.insert(g),
68 (p::GameEvent::FINISH, Some(g), _) => {
69 self.games.insert(g.clone());
70 self.ended.insert(g)
71 }
72 (p::ChallengeEvent::CREATED, _, Some(c)) => self.challenges.insert(c),
73 (p::ChallengeEvent::DECLINED | p::ChallengeEvent::CANCELED, _, Some(c)) => {
74 self.challenges.insert(c.clone());
75 self.ended.insert(c)
76 }
77 _ => true,
78 }
79 }
80}
81
82async fn event_stream(ctx: Ctx, player: i64, tx: mpsc::Sender<Bytes>) {
83 let _g = StreamGuard::new(&ctx.metrics, "event");
84 let mut notes = ctx.db.subscribe();
86 let mut sent = Sent::default();
87 if catch_up(&ctx, player, &tx, &mut sent).await.is_err() {
88 return;
89 }
90 let mut keep = tokio::time::interval(KEEPALIVE);
91 let mut seen = tokio::time::interval(Duration::from_secs(30));
92 loop {
93 tokio::select! {
94 n = notes.recv() => match n {
95 Ok(Note::User { player: p, event }) if p == player => {
96 if sent.note(&event) && tx.send(line(&event)).await.is_err() {
97 break;
98 }
99 }
100 Ok(Note::Resync) | Err(RecvError::Lagged(_)) => {
101 if catch_up(&ctx, player, &tx, &mut sent).await.is_err() {
102 break;
103 }
104 }
105 Ok(_) => {}
106 Err(RecvError::Closed) => break,
107 },
108 _ = keep.tick() => {
109 if tx.send(Bytes::from_static(b"\n")).await.is_err() {
110 break;
111 }
112 }
113 _ = seen.tick() => {
114 if let Ok(c) = ctx.db.conn().await {
115 let _ = c.execute(&ctx.db.sql("UPDATE chess.accounts SET seen_at = now() WHERE player_id = $1"), &[&player]).await;
116 }
117 }
118 }
119 }
120 debug!(player, "event stream closed");
121}
122
123async fn catch_up(ctx: &Ctx, player: i64, tx: &mpsc::Sender<Bytes>, sent: &mut Sent) -> Result<(), ()> {
127 let mut out: Vec<serde_json::Value> = Vec::new();
128 let r: anyhow::Result<()> = async {
129 let c = ctx.db.conn().await?;
130 c.execute(&ctx.db.sql("UPDATE chess.accounts SET seen_at = now() WHERE player_id = $1"), &[&player])
131 .await?;
132 let ongoing = model::ongoing_games(&ctx.db, &*c, player).await?;
133 let live: HashSet<String> = ongoing.iter().map(|g| g.id.clone()).collect();
134 out.extend(ongoing.iter().filter_map(|g| g.event(player, p::GameEvent::START)));
135 let gone: Vec<String> =
137 sent.games.iter().filter(|g| !live.contains(*g) && !sent.ended.contains(*g)).cloned().collect();
138 for id in gone {
139 if let Some(g) = model::game(&ctx.db, &*c, &id).await?
140 && !g.status.is_ongoing()
141 && let Some(e) = g.event(player, p::GameEvent::FINISH)
142 {
143 out.push(e);
144 }
145 }
146 let (incoming, outgoing) = crate::challenges::list(ctx, player).await?;
147 let open: HashSet<String> = incoming.iter().chain(&outgoing).map(|c| c.id.clone()).collect();
148 for ch in incoming.into_iter().chain(outgoing) {
149 out.push(json!({"type": "challenge", "challenge": ch, "compat": {"bot": true, "board": true}}));
150 }
151 let answered: Vec<String> = sent
153 .challenges
154 .iter()
155 .filter(|c| !open.contains(*c) && !sent.ended.contains(*c))
156 .cloned()
157 .collect();
158 for id in answered {
159 let Some(ch) = model::challenge(&ctx.db, &*c, &id, false).await? else { continue };
160 let kind = match ch.status.as_str() {
161 "declined" => p::ChallengeEvent::DECLINED,
162 "canceled" | "offline" => p::ChallengeEvent::CANCELED,
163 _ => {
164 sent.ended.insert(id);
166 continue;
167 }
168 };
169 let ratings = model::challenge_ratings(&ctx.db, &*c, &ch).await?;
170 out.push(
171 json!({"type": kind, "challenge": ch.json(ctx.cfg.public_url(), Some(player), ratings)}),
172 );
173 }
174 Ok(())
175 }
176 .await;
177 if let Err(e) = r {
178 warn!("event stream catch-up: {e:#}");
179 }
180 for e in out {
181 if sent.note(&e) && tx.send(line(&e)).await.is_err() {
182 return Err(());
183 }
184 }
185 Ok(())
186}
187
188pub async fn board_game(
191 State(ctx): State<Ctx>,
192 mp: MatchedPath,
193 who: Who,
194 Path(id): Path<String>,
195) -> ApiResult<Response> {
196 game_stream(ctx, &mp, who, id, Api::Board).await
197}
198
199pub async fn bot_game(
200 State(ctx): State<Ctx>,
201 mp: MatchedPath,
202 who: Who,
203 Path(id): Path<String>,
204) -> ApiResult<Response> {
205 game_stream(ctx, &mp, who, id, Api::Bot).await
206}
207
208async fn game_stream(ctx: Ctx, _mp: &MatchedPath, who: Who, id: String, api: Api) -> ApiResult<Response> {
209 let scope = if api == Api::Bot { "bot:play" } else { "board:play" };
210 let me = who.need(&[scope])?.player;
211 let notes = ctx.db.subscribe();
214 let c = ctx.db.conn().await?;
215 let row = model::game(&ctx.db, &*c, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
216 drop(c);
217 let side = row.side_of(me).ok_or_else(|| ApiError::not_found("game"))?;
218 api.check(row.player(side))?;
219 let slot = ctx.slot(me, "game")?;
220 let (tx, rx) = mpsc::channel::<Bytes>(64);
221 let first = line(&row.full(ctx.first_move_ms()));
222 let _ = tx.send(first).await;
223 if row.status.is_ongoing() {
224 tokio::spawn(async move {
225 follow_game(ctx, row, me, side, tx, notes).await;
226 drop(slot);
227 });
228 }
229 Ok(ndjson(rx))
230}
231
232async fn follow_game(
233 ctx: Ctx,
234 mut row: GameRow,
235 me: i64,
236 side: Side,
237 tx: mpsc::Sender<Bytes>,
238 mut notes: tokio::sync::broadcast::Receiver<Note>,
239) {
240 let _g = StreamGuard::new(&ctx.metrics, "game");
241 let fm = ctx.first_move_ms();
242 let opp = row.player(side.opposite()).player_id;
243 let state_key = |r: &GameRow| serde_json::to_string(&strip_clock(r.state(fm))).unwrap_or_default();
244 let mut last = state_key(&row);
245 let mut gone: Option<Option<i64>> = None;
246 let _ = play::touch(&ctx, &row.id, me).await;
247 let mut keep = tokio::time::interval(KEEPALIVE);
248 let beat_ms = (ctx.cfg.gone_after_secs * 1000 / 4).clamp(250, 5000);
250 let mut beat = tokio::time::interval(Duration::from_millis(beat_ms));
251 let mut check = tokio::time::interval(Duration::from_secs(2));
252 loop {
253 let reload = tokio::select! {
254 n = notes.recv() => match n {
255 Ok(Note::Game(g)) => g == row.id,
256 Ok(Note::Chat { game, line: l }) if game == row.id => {
257 if tx.send(line(&l)).await.is_err() {
258 break;
259 }
260 false
261 }
262 Ok(Note::Resync) | Err(RecvError::Lagged(_)) => true,
263 Ok(_) => false,
264 Err(RecvError::Closed) => break,
265 },
266 _ = keep.tick() => {
267 if tx.send(Bytes::from_static(b"\n")).await.is_err() {
268 break;
269 }
270 false
271 }
272 _ = beat.tick() => {
273 let _ = play::touch(&ctx, &row.id, me).await;
274 false
275 }
276 _ = check.tick() => {
277 if let Ok(c) = ctx.db.conn().await
279 && let Ok(absent) = play::absent_secs(&ctx, &*c, &row, opp).await
280 {
281 let now = play::claim_in(&ctx, absent);
282 let changed = match (gone, now) {
283 (None, _) => now.is_some(),
284 (Some(a), b) => a.is_some() != b.is_some() || (a != Some(0) && b == Some(0)),
285 };
286 gone = Some(now);
287 if changed {
288 let ev = p::OpponentGone {
289 kind: p::OpponentGone::TYPE.into(),
290 gone: now.is_some(),
291 claim_win_in_seconds: now,
292 };
293 if tx.send(line(&ev)).await.is_err() {
294 break;
295 }
296 }
297 }
298 false
299 }
300 };
301 if !reload {
302 continue;
303 }
304 let fresh = match ctx.db.conn().await {
305 Ok(c) => model::game(&ctx.db, &*c, &row.id).await,
306 Err(e) => Err(e),
307 };
308 match fresh {
309 Ok(Some(r)) => row = r,
310 Ok(None) => break,
311 Err(e) => {
312 warn!("game stream reload: {e:#}");
313 continue;
314 }
315 }
316 let key = state_key(&row);
317 if key != last {
318 last = key;
319 if tx.send(line(&row.state(fm))).await.is_err() {
320 break;
321 }
322 }
323 if !row.status.is_ongoing() {
324 break;
325 }
326 }
327}
328
329fn strip_clock(mut s: p::GameState) -> p::GameState {
331 s.wtime = 0;
332 s.btime = 0;
333 s.expiration = None;
334 s
335}
336
337pub async fn moves(State(ctx): State<Ctx>, Path(id): Path<String>) -> ApiResult<Response> {
340 let notes = ctx.db.subscribe();
341 let c = ctx.db.conn().await?;
342 let row = model::game(&ctx.db, &*c, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
343 drop(c);
344 let (tx, rx) = mpsc::channel::<Bytes>(64);
345 tokio::spawn(move_stream(ctx, row, tx, notes));
346 Ok(ndjson(rx))
347}
348
349fn describe(row: &GameRow, delay: usize) -> serde_json::Value {
352 let lines = move_lines(row);
353 let shown = if row.status.is_ongoing() { lines.len().saturating_sub(delay).max(1) } else { lines.len() };
354 let at = lines.get(shown.saturating_sub(1));
355 let first = chess_core::Game::new(row.initial_fen.as_deref()).map_or(Side::White, |g| g.first_to_move());
356 let turns = shown.saturating_sub(1);
357 let player = |s: Side| {
358 let a = row.player(s);
359 let (rating, prov, diff) = row.rating(s);
360 let mut v = json!({"user": {"name": a.username, "id": a.id()}, "rating": rating});
361 if let Some(t) = a.title() {
362 v["user"]["title"] = json!(t);
363 }
364 if prov {
365 v["provisional"] = json!(true);
366 }
367 if let Some(d) = diff {
368 v["ratingDiff"] = json!(d);
369 }
370 v
371 };
372 let mut v = json!({
373 "id": row.id,
374 "variant": row.variant(),
375 "speed": row.speed,
376 "perf": row.perf_key(),
377 "rated": row.rated,
378 "initialFen": row.initial_fen.clone().unwrap_or_else(|| chess_core::STARTPOS_FEN.into()),
379 "fen": at.map_or_else(|| row.fen.clone(), |l| l.fen.clone()),
380 "player": Side::at_ply(first, turns),
381 "turns": turns,
382 "source": row.source,
383 "status": p::StatusRef::from(row.status),
384 "createdAt": row.created_ms,
385 "players": {"white": player(Side::White), "black": player(Side::Black)},
386 });
387 if let Some(w) = row.winner {
388 v["winner"] = json!(w);
389 }
390 if let Some(lm) = at.and_then(|l| l.lm.as_ref()) {
391 v["lastMove"] = json!(lm);
392 }
393 if let (Some(i), Some(inc)) = (row.clock_initial_ms, row.clock_increment_ms) {
394 v["clock"] =
395 json!({"initial": i / 1000, "increment": inc / 1000, "totalTime": i / 1000 + 40 * inc / 1000});
396 }
397 v
398}
399
400fn move_lines(row: &GameRow) -> Vec<p::MoveLine> {
402 let Ok(mut g) = Game::new(row.initial_fen.as_deref()) else { return Vec::new() };
403 let first = g.first_to_move();
404 let init = row.clock_initial_ms.unwrap_or(0) / 1000;
405 let (mut wc, mut bc) = (init, init);
406 let mut out = vec![p::MoveLine { fen: g.fen(), lm: None, wc, bc }];
407 for (i, m) in row.moves.split_whitespace().enumerate() {
408 let Ok(played) = g.play_uci(m) else { break };
409 if row.has_clock()
410 && let Some(ms) = row.clock_ms.get(i)
411 {
412 match Side::at_ply(first, i) {
413 Side::White => wc = ms / 1000,
414 Side::Black => bc = ms / 1000,
415 }
416 }
417 out.push(p::MoveLine { fen: g.fen(), lm: Some(played.uci), wc, bc });
418 }
419 out
420}
421
422async fn move_stream(
423 ctx: Ctx,
424 mut row: GameRow,
425 tx: mpsc::Sender<Bytes>,
426 mut notes: tokio::sync::broadcast::Receiver<Note>,
427) {
428 let _g = StreamGuard::new(&ctx.metrics, "moves");
429 let delay = ctx.cfg.public_delay_plies;
430 if tx.send(line(&describe(&row, delay))).await.is_err() {
431 return;
432 }
433 let mut sent = 0usize;
435 let mut keep = tokio::time::interval(KEEPALIVE);
436 loop {
437 let lines = move_lines(&row);
438 let visible =
439 if row.status.is_ongoing() { lines.len().saturating_sub(delay).max(1) } else { lines.len() };
440 for l in lines.iter().take(visible).skip(sent) {
441 if tx.send(line(l)).await.is_err() {
442 return;
443 }
444 }
445 sent = sent.max(visible);
446 if !row.status.is_ongoing() {
447 let _ = tx.send(line(&describe(&row, delay))).await;
448 return;
449 }
450 let reload = loop {
451 tokio::select! {
452 n = notes.recv() => match n {
453 Ok(Note::Game(g)) if g == row.id => break true,
454 Ok(Note::Resync) | Err(RecvError::Lagged(_)) => break true,
455 Err(RecvError::Closed) => return,
456 Ok(_) => {}
457 },
458 _ = keep.tick() => {
459 if tx.send(Bytes::from_static(b"\n")).await.is_err() {
460 return;
461 }
462 }
463 }
464 };
465 if reload {
466 match ctx.db.conn().await {
467 Ok(c) => match model::game(&ctx.db, &*c, &row.id).await {
468 Ok(Some(r)) => row = r,
469 Ok(None) => return,
470 Err(e) => warn!("move stream reload: {e:#}"),
471 },
472 Err(e) => warn!("move stream reload: {e:#}"),
473 }
474 }
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn sent_events_are_not_repeated() {
484 let mut s = Sent::default();
485 let start = json!({"type": "gameStart", "game": {"id": "g1"}});
486 let finish = json!({"type": "gameFinish", "game": {"id": "g1"}});
487 let ch = json!({"type": "challenge", "challenge": {"id": "c1"}});
488 let declined = json!({"type": "challengeDeclined", "challenge": {"id": "c1"}});
489 assert!(s.note(&start));
490 assert!(!s.note(&start), "a gameStart once");
491 assert!(s.note(&finish));
492 assert!(!s.note(&finish), "a gameFinish once");
493 assert!(s.note(&ch) && s.note(&declined) && !s.note(&declined));
494 assert!(s.note(&json!({"type": "somethingNew"})), "unknown kinds pass");
495 assert!(s.ended.contains("g1") && s.ended.contains("c1"));
496 }
497}