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