Skip to main content

open_chess_server/
config.rs

1//! Settings, all from the environment (containers on Kubernetes and
2//! Cloudflare both pass configuration that way). Every variable has a
3//! working default except `DATABASE_URL`.
4
5use std::time::Duration;
6
7use anyhow::{Context, Result, bail};
8use clap::Args;
9
10#[derive(Debug, Clone, Args)]
11#[allow(clippy::struct_excessive_bools)] // settings switches
12pub struct Config {
13    /// Postgres URL: the site's Neon database. A pooled (`-pooler`) host is
14    /// fine; notifications use the direct host automatically.
15    #[arg(long, env = "DATABASE_URL", hide_env_values = true)]
16    pub database_url: String,
17
18    /// A separate URL for LISTEN, if the direct host can't be derived.
19    #[arg(long, env = "CHESS_LISTEN_DATABASE_URL", hide_env_values = true)]
20    pub listen_database_url: Option<String>,
21
22    /// The site's schema (players, API tokens, sessions).
23    #[arg(long, env = "CHESS_SITE_SCHEMA", default_value = "esports")]
24    pub site_schema: String,
25
26    /// This server's own schema (games, challenges, ratings, fair play).
27    #[arg(long, env = "CHESS_SCHEMA", default_value = "chess")]
28    pub schema: String,
29
30    #[arg(long, env = "CHESS_POOL_SIZE", default_value_t = 8)]
31    pub pool_size: usize,
32
33    /// Where clients reach this server, including any path prefix
34    /// (`https://chess.example.com`). Used in game URLs and
35    /// to mount the API under the prefix.
36    #[arg(long, env = "CHESS_PUBLIC_URL", default_value = "http://localhost:8090")]
37    pub public_url: String,
38
39    /// The site whose players and sign-ins this server uses, for "sign in
40    /// first" links on the OAuth page.
41    #[arg(long, env = "ESPORTS_PUBLIC_URL", default_value = "http://localhost:8080")]
42    pub site_url: String,
43
44    /// Sign in through the host site (esports-lab): the OAuth page accepts
45    /// the site's session cookie and links to its sign-in. Off by default,
46    /// so a server without that site offers no browser sign-in; API
47    /// tokens (the site's, and `open-chess bot create`) work either way.
48    #[arg(
49        long,
50        env = "ESPORTS_LOGIN",
51        default_value_t = false,
52        num_args = 0..=1,
53        default_missing_value = "true",
54        action = clap::ArgAction::Set,
55        value_parser = clap::builder::BoolishValueParser::new()
56    )]
57    pub esports_login: bool,
58
59    /// What players know the site as: on the OAuth page, in PGN event
60    /// names and in `GET /` (which the training app shows).
61    #[arg(long, env = "CHESS_SITE_NAME", default_value = "open-chess")]
62    pub site_name: String,
63
64    #[arg(long, env = "CHESS_BIND", default_value = "0.0.0.0:8090")]
65    pub bind: String,
66
67    /// /metrics, /healthz and /readyz.
68    #[arg(long, env = "CHESS_METRICS_BIND", default_value = "0.0.0.0:9465")]
69    pub metrics_bind: String,
70
71    /// Milliseconds given back per move for the network round trip.
72    #[arg(long, env = "CHESS_LAG_COMP_MS", default_value_t = 100)]
73    pub lag_comp_ms: i64,
74
75    /// A real-time challenge lapses after this many seconds unless the
76    /// challenger keeps a `keepAliveStream` open.
77    #[arg(long, env = "CHESS_CHALLENGE_TTL_SECS", default_value_t = 20)]
78    pub challenge_ttl_secs: u64,
79
80    /// Each side's first move must come within this many seconds or the
81    /// game is aborted.
82    #[arg(long, env = "CHESS_FIRST_MOVE_SECS", default_value_t = 30)]
83    pub first_move_secs: u64,
84
85    /// A player with no open game stream for this long is "gone".
86    #[arg(long, env = "CHESS_GONE_AFTER_SECS", default_value_t = 20)]
87    pub gone_after_secs: u64,
88
89    /// Then the opponent may claim the win after this many more seconds.
90    #[arg(long, env = "CHESS_CLAIM_WIN_SECS", default_value_t = 10)]
91    pub claim_win_secs: u64,
92
93    /// The public move stream runs this many plies behind a live game, so
94    /// it can't feed an engine helping a player.
95    #[arg(long, env = "CHESS_PUBLIC_DELAY_PLIES", default_value_t = 3)]
96    pub public_delay_plies: usize,
97
98    /// Queue human players' finished games for fair-play analysis.
99    #[arg(long, env = "CHESS_FAIRPLAY", default_value_t = true, action = clap::ArgAction::Set)]
100    pub fairplay: bool,
101
102    /// Accounts marked for engine use can't start new games.
103    #[arg(long, env = "CHESS_BLOCK_MARKED", default_value_t = true, action = clap::ArgAction::Set)]
104    pub block_marked: bool,
105
106    /// How often the clock sweeper looks for flags and lapsed challenges.
107    #[arg(long, env = "CHESS_SWEEP_MS", default_value_t = 500)]
108    pub sweep_ms: u64,
109
110    /// Reading boards from photos: base URL of a vision model behind an
111    /// OpenAI-style `/chat/completions` (Claude via
112    /// `https://api.anthropic.com/v1`, Qwen-VL via Ollama's
113    /// `http://localhost:11434/v1`, or a gateway). Empty: photo reading is
114    /// off.
115    #[arg(long, env = "CHESS_VISION_ENDPOINT", default_value = "")]
116    pub vision_endpoint: String,
117
118    /// The vision model's name as that server knows it.
119    #[arg(long, env = "CHESS_VISION_MODEL", default_value = "qwen3-vl:8b")]
120    pub vision_model: String,
121
122    /// Bearer token for the vision server, if it needs one.
123    #[arg(long, env = "CHESS_VISION_API_KEY", hide_env_values = true)]
124    pub vision_api_key: Option<String>,
125
126    /// Our own board reader (chess-vision): a `.safetensors` weights file
127    /// from `chess-vision-train`. It reads photos first, on this
128    /// server's CPU; the vision model at CHESS_VISION_ENDPOINT (if set) is
129    /// only asked about photos it isn't sure of. Empty: not used.
130    #[arg(long, env = "CHESS_VISION_LOCAL_MODEL", default_value = "")]
131    pub vision_local_model: String,
132
133    /// The board reader's answer is used without review when every square
134    /// is above its unsure threshold and the chance that all 64 are right
135    /// is at least this.
136    #[arg(long, env = "CHESS_VISION_MIN_CONFIDENCE", default_value_t = 0.5)]
137    pub vision_min_confidence: f32,
138
139    /// Keep photos that couldn't be read for sure for the Review page,
140    /// where a person confirms the position; confirmed photos become
141    /// training data (`open-chess photos export`). Players are told.
142    #[arg(long, env = "CHESS_VISION_REVIEW", default_value_t = true, action = clap::ArgAction::Set)]
143    pub vision_review: bool,
144
145    /// At most this many photos wait for review; beyond it, new ones
146    /// aren't kept.
147    #[arg(long, env = "CHESS_VISION_REVIEW_MAX", default_value_t = 5000)]
148    pub vision_review_max: i64,
149
150    /// Where exact endgame results (up to seven pieces) come from: a
151    /// Lichess-compatible tablebase server. Answers are kept in the
152    /// database, so each position is fetched once. Empty: off.
153    #[arg(long, env = "CHESS_TABLEBASE_URL", default_value = "https://tablebase.lichess.ovh")]
154    pub tablebase_url: String,
155
156    /// The built training app (web/chess plus its wasm engine), served at
157    /// `/app/`. The chess image puts it at /usr/share/open-chess/app.
158    #[arg(long, env = "CHESS_APP_DIR")]
159    pub app_dir: Option<String>,
160}
161
162impl Config {
163    /// The path the API lives under (`/chess`, or empty).
164    pub fn base_path(&self) -> Result<String> {
165        let u = url::Url::parse(&self.public_url).context("CHESS_PUBLIC_URL isn't a URL")?;
166        let p = u.path().trim_end_matches('/');
167        Ok(p.to_owned())
168    }
169
170    pub fn public_url(&self) -> &str {
171        self.public_url.trim_end_matches('/')
172    }
173
174    pub fn challenge_ttl(&self) -> Duration {
175        Duration::from_secs(self.challenge_ttl_secs.max(5))
176    }
177
178    pub fn validate(&self) -> Result<()> {
179        for s in [&self.schema, &self.site_schema] {
180            if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
181                bail!("schema names are letters, digits and _ (got {s:?})");
182            }
183        }
184        if self.schema == self.site_schema {
185            bail!("CHESS_SCHEMA must differ from CHESS_SITE_SCHEMA");
186        }
187        self.base_path()?;
188        Ok(())
189    }
190}
191
192/// Settings for the fair-play worker (`open-chess analyse`).
193#[derive(Debug, Clone, Args)]
194pub struct AnalyseConfig {
195    /// The UCI engine (Stockfish).
196    #[arg(long, env = "CHESS_STOCKFISH")]
197    pub stockfish: Option<String>,
198
199    /// Search depth per position.
200    #[arg(long, env = "CHESS_ANALYSIS_DEPTH", default_value_t = 14)]
201    pub depth: u32,
202
203    /// Engine threads.
204    #[arg(long, env = "CHESS_ANALYSIS_THREADS", default_value_t = 1)]
205    pub threads: u32,
206
207    /// Engine hash, MiB.
208    #[arg(long, env = "CHESS_ANALYSIS_HASH_MB", default_value_t = 64)]
209    pub hash_mb: u32,
210
211    /// Plies at the start left out of screening (opening theory).
212    #[arg(long, env = "CHESS_FAIRPLAY_SKIP_PLIES", default_value_t = 16)]
213    pub skip_plies: usize,
214
215    /// The screening model: how the fair-play signals turn into a score
216    /// and a flag (`chess_fairplay::Model`, as JSON). Either a path to a
217    /// JSON file or the JSON itself (handy as a secret). Keep a tuned
218    /// model private: its numbers tell people what screening looks for.
219    /// Unset: an untuned example model, and a warning.
220    #[arg(long, env = "CHESS_FAIRPLAY_MODEL", hide_env_values = true)]
221    pub model: Option<String>,
222
223    /// Overrides the model's flag score (0–100).
224    #[arg(long, env = "CHESS_FAIRPLAY_FLAG_SCORE")]
225    pub flag_score: Option<f64>,
226
227    /// Overrides the model's fewest screened moves before a game can be
228    /// flagged.
229    #[arg(long, env = "CHESS_FAIRPLAY_MIN_MOVES")]
230    pub min_moves: Option<usize>,
231
232    /// The training app's engine settings, for the app fingerprint (the
233    /// app's "Normal" search). Keep in step with web/chess/app.js.
234    #[arg(long, env = "CHESS_FAIRPLAY_APP_DEPTH", default_value_t = 6)]
235    pub app_depth: u8,
236
237    #[arg(long, env = "CHESS_FAIRPLAY_APP_NODES", default_value_t = 400_000)]
238    pub app_nodes: u64,
239
240    /// The NNUE weight file the app ships (web/chess/nets/default.nnue),
241    /// if it ships one, so the fingerprint uses the same evaluation.
242    #[arg(long, env = "CHESS_FAIRPLAY_APP_NET")]
243    pub app_net: Option<String>,
244
245    /// Analyse one game and exit (for testing).
246    #[arg(long)]
247    pub once: bool,
248}
249
250impl AnalyseConfig {
251    /// The screening model: `CHESS_FAIRPLAY_MODEL` (a JSON file or inline
252    /// JSON), else the untuned example, with any overrides applied.
253    /// Returns whether a model was configured, so callers can warn.
254    pub fn model(&self) -> Result<(chess_fairplay::Model, bool)> {
255        let (mut model, configured) = match self.model.as_deref().map(str::trim) {
256            None | Some("") => (chess_fairplay::Model::default(), false),
257            Some(json) if json.starts_with('{') => {
258                (serde_json::from_str(json).context("CHESS_FAIRPLAY_MODEL isn't a valid model")?, true)
259            }
260            Some(path) => {
261                let text = std::fs::read_to_string(path)
262                    .with_context(|| format!("reading the fair-play model {path}"))?;
263                (serde_json::from_str(&text).with_context(|| format!("{path} isn't a valid model"))?, true)
264            }
265        };
266        if let Some(s) = self.flag_score {
267            model.flag_score = s;
268        }
269        if let Some(n) = self.min_moves {
270            model.min_moves = n;
271        }
272        model.validate().map_err(|e| anyhow::anyhow!("fair-play model: {e}"))?;
273        Ok((model, configured))
274    }
275}