1use std::net::SocketAddr;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8pub const DEFAULT_GAME_PORT: u16 = 7373;
9pub const DEFAULT_API_PORT: u16 = 7380;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
12pub struct GfxWindowPrefs {
13 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub width: Option<u32>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub height: Option<u32>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub x: Option<u32>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub y: Option<u32>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
29pub struct FloatingChatPrefs {
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub x: Option<f32>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub y: Option<f32>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub collapsed: Option<bool>,
39}
40
41impl FloatingChatPrefs {
42 pub fn is_empty(&self) -> bool {
43 self.x.is_none() && self.y.is_none() && self.collapsed.is_none()
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct ClientConfig {
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub game_host: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub api_port: Option<u16>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub game_port: Option<u16>,
58 #[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
60 pub gfx_window: GfxWindowPrefs,
61 #[serde(default, skip_serializing_if = "FloatingChatPrefs::is_empty")]
63 pub floating_chat: FloatingChatPrefs,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub hud_view: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub hud_log_hidden: Option<bool>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub workers_menu_compact: Option<bool>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub worker_route_panel_collapsed: Option<bool>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub check_updates: Option<bool>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub update_dismissed_version: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub auto_nav_mode: Option<String>,
85}
86
87impl GfxWindowPrefs {
88 pub fn is_empty(&self) -> bool {
89 self.width.is_none() && self.height.is_none() && self.x.is_none() && self.y.is_none()
90 }
91}
92
93impl ClientConfig {
94 pub fn path() -> anyhow::Result<PathBuf> {
95 let base = dirs::config_dir()
96 .ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
97 Ok(base.join("flatland").join("client.json"))
98 }
99
100 pub fn load() -> Self {
101 Self::path()
102 .ok()
103 .and_then(|path| std::fs::read(&path).ok())
104 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
105 .unwrap_or_default()
106 }
107
108 pub fn save(&self) -> anyhow::Result<()> {
109 let path = Self::path()?;
110 if let Some(parent) = path.parent() {
111 std::fs::create_dir_all(parent)?;
112 }
113 std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
114 Ok(())
115 }
116
117 pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
118 self.gfx_window = prefs;
119 self.save()
120 }
121
122 pub fn save_floating_chat(&mut self, prefs: FloatingChatPrefs) -> anyhow::Result<()> {
124 self.floating_chat = prefs;
125 self.save()
126 }
127
128 pub fn save_hud_view(&mut self, label: &str) -> anyhow::Result<()> {
130 self.hud_view = Some(label.to_string());
131 self.save()
132 }
133
134 pub fn save_hud_log_hidden(&mut self, hidden: bool) -> anyhow::Result<()> {
135 self.hud_log_hidden = Some(hidden);
136 self.save()
137 }
138
139 pub fn save_workers_menu_compact(&mut self, compact: bool) -> anyhow::Result<()> {
140 self.workers_menu_compact = Some(compact);
141 self.save()
142 }
143
144 pub fn save_worker_route_panel_collapsed(&mut self, collapsed: bool) -> anyhow::Result<()> {
145 self.worker_route_panel_collapsed = Some(collapsed);
146 self.save()
147 }
148
149 pub fn save_update_dismissed_version(&mut self, version: &str) -> anyhow::Result<()> {
150 self.update_dismissed_version = Some(version.to_string());
151 self.save()
152 }
153
154 pub fn auto_nav_path_mode(&self) -> flatland_pathfinding::PathMode {
156 self.auto_nav_mode
157 .as_deref()
158 .map(flatland_pathfinding::PathMode::parse)
159 .unwrap_or_default()
160 }
161
162 pub fn save_auto_nav_mode(
163 &mut self,
164 mode: flatland_pathfinding::PathMode,
165 ) -> anyhow::Result<()> {
166 self.auto_nav_mode = Some(mode.as_str().to_string());
167 self.save()
168 }
169
170 pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
171 let host = normalize_host_input(&host.into())?;
172 let default_port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
173 let (host_only, port) = split_host_port(&host, default_port);
174 self.game_host = Some(host_only);
175 if port != DEFAULT_GAME_PORT || self.game_port.is_some() {
176 self.game_port = Some(port);
177 }
178 self.save()
179 }
180
181 pub fn display_game_host(&self) -> String {
183 self.game_host.clone().unwrap_or_else(|| "127.0.0.1".into())
184 }
185
186 pub fn resolve_game_server_addr(&self) -> anyhow::Result<std::net::SocketAddr> {
188 use std::net::ToSocketAddrs;
189 let host = self.game_host.as_deref().unwrap_or("127.0.0.1");
190 let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
191 let (host, port) = split_host_port(host, port);
192 let target = format!("{host}:{port}");
193 target
194 .to_socket_addrs()
195 .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
196 .next()
197 .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
198 }
199
200 pub fn game_server_addr(&self) -> SocketAddr {
201 self.resolve_game_server_addr()
202 .unwrap_or_else(|_| "127.0.0.1:7373".parse().expect("valid default addr"))
203 }
204
205 pub fn api_base_url(&self) -> String {
206 if let Some(host) = self.game_host.as_deref() {
207 let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
208 if host.starts_with("http://") || host.starts_with("https://") {
209 return host.trim_end_matches('/').to_string();
210 }
211 let (host, _) = split_host_port(host, port);
212 return format!("http://{host}:{port}");
213 }
214 "http://127.0.0.1:7380".into()
215 }
216}
217
218pub fn default_game_server_addr() -> SocketAddr {
219 if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
220 if let Ok(parsed) = addr.parse() {
221 return parsed;
222 }
223 if let Ok(resolved) = resolve_game_host_addr(&addr) {
225 return resolved;
226 }
227 }
228 ClientConfig::load().game_server_addr()
229}
230
231pub fn default_api_base_url() -> String {
232 if let Ok(url) = std::env::var("FLATLAND_API_URL") {
233 return url;
234 }
235 ClientConfig::load().api_base_url()
236}
237
238pub fn resolve_game_host_addr(host: &str) -> anyhow::Result<SocketAddr> {
242 use std::net::ToSocketAddrs;
243 let host = normalize_host_input(host)?;
244 let (host, port) = split_host_port(&host, DEFAULT_GAME_PORT);
245 let target = format!("{host}:{port}");
246 target
247 .to_socket_addrs()
248 .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
249 .next()
250 .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
251}
252
253pub fn api_base_url_for_host(host: &str) -> anyhow::Result<String> {
256 let host = normalize_host_input(host)?;
257 if host.starts_with("http://") || host.starts_with("https://") {
258 return Ok(host.trim_end_matches('/').to_string());
259 }
260 let (host, _) = split_host_port(&host, DEFAULT_API_PORT);
261 Ok(format!("http://{host}:{DEFAULT_API_PORT}"))
262}
263
264pub fn apply_host_override(host: &str) -> anyhow::Result<(SocketAddr, String)> {
271 let normalized = normalize_host_input(host)?;
272 let game = resolve_game_host_addr(&normalized)?;
273 let api = api_base_url_for_host(&normalized)?;
274 let (host_only, _) = split_host_port(&normalized, DEFAULT_GAME_PORT);
275
276 std::env::set_var("FLATLAND_HOST_OVERRIDE", &host_only);
277 std::env::set_var("FLATLAND_API_URL", &api);
278 std::env::set_var("FLATLAND_GATEWAY_ADDR", game.to_string());
279
280 if std::env::var_os("FLATLAND_SESSION_PATH").is_none() {
281 if let Some(base) = dirs::config_dir() {
282 let safe: String = host_only
283 .chars()
284 .map(|c| {
285 if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
286 c
287 } else {
288 '_'
289 }
290 })
291 .collect();
292 let path = base.join("flatland").join(format!("session.{safe}.json"));
293 std::env::set_var("FLATLAND_SESSION_PATH", path);
294 }
295 }
296
297 Ok((game, api))
298}
299
300pub fn host_override_active() -> bool {
302 std::env::var_os("FLATLAND_HOST_OVERRIDE").is_some()
303}
304
305pub fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
307 let mut host = raw.trim().to_string();
308 if host.is_empty() {
309 anyhow::bail!("game host cannot be empty");
310 }
311 if let Some(rest) = host.strip_prefix("https://") {
312 host = rest.to_string();
313 } else if let Some(rest) = host.strip_prefix("http://") {
314 host = rest.to_string();
315 }
316 if let Some((h, _)) = host.split_once('/') {
317 host = h.to_string();
318 }
319 host = host.trim_end_matches('/').to_string();
320 if host.is_empty() {
321 anyhow::bail!("game host cannot be empty");
322 }
323 Ok(host)
324}
325
326fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
327 if let Some(rest) = host.strip_prefix('[') {
329 if let Some((addr, port_part)) = rest.split_once("]:") {
330 if let Ok(p) = port_part.parse::<u16>() {
331 return (format!("[{addr}]"), p);
332 }
333 }
334 return (host.to_string(), default_port);
335 }
336 if let Some((h, p)) = host.rsplit_once(':') {
338 if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
339 return (h.to_string(), p.parse().unwrap_or(default_port));
340 }
341 }
342 (host.to_string(), default_port)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn default_is_localhost() {
351 let cfg = ClientConfig::default();
352 assert_eq!(cfg.game_server_addr(), "127.0.0.1:7373".parse().unwrap());
353 assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
354 }
355
356 #[test]
357 fn hud_view_roundtrips_in_config() {
358 let mut cfg = ClientConfig::default();
359 cfg.hud_view = Some("map".into());
360 let json = serde_json::to_string(&cfg).unwrap();
361 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
362 assert_eq!(loaded.hud_view.as_deref(), Some("map"));
363 }
364
365 #[test]
366 fn hud_and_workers_prefs_roundtrip() {
367 let mut cfg = ClientConfig::default();
368 cfg.hud_log_hidden = Some(true);
369 cfg.workers_menu_compact = Some(true);
370 cfg.worker_route_panel_collapsed = Some(true);
371 let json = serde_json::to_string(&cfg).unwrap();
372 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
373 assert_eq!(loaded.hud_log_hidden, Some(true));
374 assert_eq!(loaded.workers_menu_compact, Some(true));
375 assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
376 }
377
378 #[test]
379 fn floating_chat_prefs_roundtrip() {
380 let mut cfg = ClientConfig::default();
381 cfg.floating_chat = FloatingChatPrefs {
382 x: Some(42.5),
383 y: Some(100.0),
384 collapsed: Some(true),
385 };
386 let json = serde_json::to_string(&cfg).unwrap();
387 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
388 assert_eq!(loaded.floating_chat.x, Some(42.5));
389 assert_eq!(loaded.floating_chat.y, Some(100.0));
390 assert_eq!(loaded.floating_chat.collapsed, Some(true));
391 }
392
393 #[test]
394 fn split_host_port_handles_dns_and_explicit_port() {
395 assert_eq!(
396 split_host_port("server1.flatland3.com", 7373),
397 ("server1.flatland3.com".into(), 7373)
398 );
399 assert_eq!(
400 split_host_port("server1.flatland3.com:7374", 7373),
401 ("server1.flatland3.com".into(), 7374)
402 );
403 assert_eq!(
404 split_host_port("127.0.0.1", 7373),
405 ("127.0.0.1".into(), 7373)
406 );
407 }
408
409 #[test]
410 fn normalize_strips_scheme() {
411 assert_eq!(
412 normalize_host_input("https://server1.flatland3.com/").unwrap(),
413 "server1.flatland3.com"
414 );
415 }
416
417 #[test]
418 fn api_base_uses_dns_host() {
419 let mut cfg = ClientConfig::default();
420 cfg.game_host = Some("server1.flatland3.com".into());
421 assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
422 }
423
424 #[test]
425 fn api_base_url_for_host_defaults_port() {
426 assert_eq!(
427 api_base_url_for_host("server2.flatland3.com").unwrap(),
428 "http://server2.flatland3.com:7380"
429 );
430 assert_eq!(
431 api_base_url_for_host("server2.flatland3.com:7373").unwrap(),
432 "http://server2.flatland3.com:7380"
433 );
434 }
435}