Skip to main content

open_chess_server/http/
fairplay.rs

1//! Fair-play review (open-chess endpoints, not Lichess's): the
2//! queue of flagged games, a game's reports, a player's record, and
3//! reviewers' verdicts. Needs a token with `chess:fairplay` held by a
4//! manager or an admin.
5
6use axum::Json;
7use axum::extract::{Path, Query, State};
8use axum::response::{IntoResponse, Response};
9use serde::Deserialize;
10use serde_json::{Value, json};
11
12use crate::app::{Ctx, Who};
13use crate::auth::Identity;
14use crate::error::{ApiError, ApiResult};
15use crate::http::ok;
16use crate::model;
17
18fn reviewer(who: &Who) -> ApiResult<&Identity> {
19    let id = who.need(&["chess:fairplay"])?;
20    if !(id.admin || id.manager) {
21        return Err(ApiError::forbidden("fair-play review is for managers and admins"));
22    }
23    Ok(id)
24}
25
26const REPORT_COLS: &str =
27    "r.game_id, r.side, a.username, r.score, r.flagged, r.moves, r.accuracy, r.acpl, r.top1, r.top3,
28    r.time_cv, r.time_corr, r.reasons, r.engine, r.depth, (extract(epoch from r.created_at) * 1000)::bigint,
29    r.app_disagree, r.app_picked, r.app_picked_reference, r.declared_assist";
30
31fn report(r: &tokio_postgres::Row) -> Value {
32    json!({
33        "gameId": r.get::<_, String>(0),
34        "side": r.get::<_, String>(1),
35        "user": r.get::<_, String>(2),
36        "score": r.get::<_, f32>(3),
37        "flagged": r.get::<_, bool>(4),
38        "moves": r.get::<_, i32>(5),
39        "accuracy": r.get::<_, f32>(6),
40        "acpl": r.get::<_, f32>(7),
41        "top1": r.get::<_, f32>(8),
42        "top3": r.get::<_, f32>(9),
43        "timeCv": r.get::<_, Option<f32>>(10),
44        "timeCorrelation": r.get::<_, Option<f32>>(11),
45        "reasons": r.get::<_, Vec<String>>(12),
46        "engine": r.get::<_, String>(13),
47        "depth": r.get::<_, i32>(14),
48        "createdAt": r.get::<_, i64>(15),
49        // Where Stockfish and the training app's engine disagreed, which
50        // the player chose.
51        "appFingerprint": {
52            "disagreements": r.get::<_, Option<i32>>(16),
53            "pickedApp": r.get::<_, Option<i32>>(17),
54            "pickedStockfish": r.get::<_, Option<i32>>(18),
55        },
56        // The app said its engine help was on during this game.
57        "declaredAssist": r.get::<_, bool>(19),
58    })
59}
60
61#[derive(Deserialize)]
62pub struct QueueQuery {
63    limit: Option<i64>,
64    /// Include games that have a verdict already.
65    all: Option<bool>,
66}
67
68/// Flagged reports, newest first (unreviewed only unless `all=true`).
69pub async fn queue(State(ctx): State<Ctx>, who: Who, Query(q): Query<QueueQuery>) -> ApiResult<Response> {
70    reviewer(&who)?;
71    let c = ctx.db.conn().await?;
72    let rows = c
73        .query(
74            &ctx.db.sql(&format!(
75                "SELECT {REPORT_COLS} FROM chess.fairplay_reports r JOIN chess.accounts a ON a.player_id = r.player_id
76                  WHERE r.flagged AND ($2 OR NOT EXISTS (
77                        SELECT 1 FROM chess.fairplay_reviews v WHERE v.player_id = r.player_id AND v.game_id = r.game_id))
78                  ORDER BY r.created_at DESC LIMIT $1"
79            )),
80            &[&q.limit.unwrap_or(50).clamp(1, 500), &q.all.unwrap_or(false)],
81        )
82        .await?;
83    Ok(Json(rows.iter().map(report).collect::<Vec<_>>()).into_response())
84}
85
86/// Both sides' reports for one game, with the per-move evaluations.
87pub async fn game(State(ctx): State<Ctx>, who: Who, Path(id): Path<String>) -> ApiResult<Response> {
88    reviewer(&who)?;
89    let c = ctx.db.conn().await?;
90    let rows = c
91        .query(
92            &ctx.db.sql(&format!(
93                "SELECT {REPORT_COLS}, r.evals FROM chess.fairplay_reports r JOIN chess.accounts a ON a.player_id = r.player_id
94                  WHERE r.game_id = $1 ORDER BY r.side DESC"
95            )),
96            &[&id],
97        )
98        .await?;
99    let analysis: Option<String> = c
100        .query_opt(&ctx.db.sql("SELECT analysis FROM chess.games WHERE id = $1"), &[&id])
101        .await?
102        .map(|r| r.get(0));
103    let Some(analysis) = analysis else { return Err(ApiError::not_found("game")) };
104    let reports: Vec<Value> = rows
105        .iter()
106        .map(|r| {
107            let mut v = report(r);
108            v["evals"] = r.get::<_, Value>(20);
109            v
110        })
111        .collect();
112    Ok(Json(json!({"gameId": id, "analysis": analysis, "reports": reports})).into_response())
113}
114
115/// A player's screening record and the verdicts on them.
116pub async fn player(State(ctx): State<Ctx>, who: Who, Path(username): Path<String>) -> ApiResult<Response> {
117    reviewer(&who)?;
118    let c = ctx.db.conn().await?;
119    let a =
120        model::account_by_name(&ctx.db, &*c, &username).await?.ok_or_else(|| ApiError::not_found("user"))?;
121    let s = c
122        .query_one(
123            &ctx.db.sql(
124                "SELECT count(*), count(*) FILTER (WHERE flagged), coalesce(avg(score), 0)::real,
125                        coalesce(avg(score) FILTER (WHERE created_at > now() - interval '30 days'), 0)::real
126                   FROM chess.fairplay_reports WHERE player_id = $1",
127            ),
128            &[&a.player_id],
129        )
130        .await?;
131    let recent = c
132        .query(
133            &ctx.db.sql(&format!(
134                "SELECT {REPORT_COLS} FROM chess.fairplay_reports r JOIN chess.accounts a ON a.player_id = r.player_id
135                  WHERE r.player_id = $1 ORDER BY r.created_at DESC LIMIT 20"
136            )),
137            &[&a.player_id],
138        )
139        .await?;
140    let reviews: Vec<Value> = c
141        .query(
142            &ctx.db.sql(
143                "SELECT v.game_id, v.verdict, v.note, p.name, (extract(epoch from v.at) * 1000)::bigint
144                   FROM chess.fairplay_reviews v LEFT JOIN site.players p ON p.id = v.reviewer_id
145                  WHERE v.player_id = $1 ORDER BY v.at DESC LIMIT 50",
146            ),
147            &[&a.player_id],
148        )
149        .await?
150        .iter()
151        .map(|r| {
152            json!({"gameId": r.get::<_, Option<String>>(0), "verdict": r.get::<_, String>(1),
153                   "note": r.get::<_, Option<String>>(2), "reviewer": r.get::<_, Option<String>>(3),
154                   "at": r.get::<_, i64>(4)})
155        })
156        .collect();
157    Ok(Json(json!({
158        "user": a.username,
159        "bot": a.bot,
160        "marked": a.marked,
161        "screened": s.get::<_, i64>(0),
162        "flagged": s.get::<_, i64>(1),
163        "meanScore": s.get::<_, f32>(2),
164        "meanScore30d": s.get::<_, f32>(3),
165        "recent": recent.iter().map(report).collect::<Vec<_>>(),
166        "reviews": reviews,
167    }))
168    .into_response())
169}
170
171#[derive(Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct Review {
174    username: String,
175    game_id: Option<String>,
176    /// clean | engine | unclear
177    verdict: String,
178    note: Option<String>,
179}
180
181/// Record a verdict. `engine` marks the account (shown as tosViolation;
182/// it can't start games while `CHESS_BLOCK_MARKED` is on); `clean` lifts a
183/// mark.
184pub async fn review(State(ctx): State<Ctx>, who: Who, Json(r): Json<Review>) -> ApiResult<Response> {
185    let me = reviewer(&who)?;
186    if !matches!(r.verdict.as_str(), "clean" | "engine" | "unclear") {
187        return Err(ApiError::bad("verdict is clean, engine or unclear"));
188    }
189    let mut c = ctx.db.conn().await?;
190    let tx = c.transaction().await?;
191    let a = model::account_by_name(&ctx.db, &tx, &r.username)
192        .await?
193        .ok_or_else(|| ApiError::not_found("user"))?;
194    tx.execute(
195        &ctx.db.sql(
196            "INSERT INTO chess.fairplay_reviews (player_id, game_id, verdict, note, reviewer_id) VALUES ($1, $2, $3, $4, $5)",
197        ),
198        &[&a.player_id, &r.game_id, &r.verdict, &r.note, &me.player],
199    )
200    .await?;
201    match r.verdict.as_str() {
202        "engine" => {
203            tx.execute(
204                &ctx.db
205                    .sql("UPDATE chess.accounts SET marked_at = now(), marked_by = $2 WHERE player_id = $1"),
206                &[&a.player_id, &me.player],
207            )
208            .await?;
209        }
210        "clean" => {
211            tx.execute(
212                &ctx.db
213                    .sql("UPDATE chess.accounts SET marked_at = NULL, marked_by = NULL WHERE player_id = $1"),
214                &[&a.player_id],
215            )
216            .await?;
217        }
218        _ => {}
219    }
220    tx.commit().await?;
221    Ok(ok())
222}