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()
90 && self.height.is_none()
91 && self.x.is_none()
92 && self.y.is_none()
93 }
94}
95
96impl ClientConfig {
97 pub fn path() -> anyhow::Result<PathBuf> {
98 let base = dirs::config_dir()
99 .ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
100 Ok(base.join("flatland").join("client.json"))
101 }
102
103 pub fn load() -> Self {
104 Self::path()
105 .ok()
106 .and_then(|path| std::fs::read(&path).ok())
107 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
108 .unwrap_or_default()
109 }
110
111 pub fn save(&self) -> anyhow::Result<()> {
112 let path = Self::path()?;
113 if let Some(parent) = path.parent() {
114 std::fs::create_dir_all(parent)?;
115 }
116 std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
117 Ok(())
118 }
119
120 pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
121 self.gfx_window = prefs;
122 self.save()
123 }
124
125 pub fn save_floating_chat(&mut self, prefs: FloatingChatPrefs) -> anyhow::Result<()> {
127 self.floating_chat = prefs;
128 self.save()
129 }
130
131 pub fn save_hud_view(&mut self, label: &str) -> anyhow::Result<()> {
133 self.hud_view = Some(label.to_string());
134 self.save()
135 }
136
137 pub fn save_hud_log_hidden(&mut self, hidden: bool) -> anyhow::Result<()> {
138 self.hud_log_hidden = Some(hidden);
139 self.save()
140 }
141
142 pub fn save_workers_menu_compact(&mut self, compact: bool) -> anyhow::Result<()> {
143 self.workers_menu_compact = Some(compact);
144 self.save()
145 }
146
147 pub fn save_worker_route_panel_collapsed(&mut self, collapsed: bool) -> anyhow::Result<()> {
148 self.worker_route_panel_collapsed = Some(collapsed);
149 self.save()
150 }
151
152 pub fn save_update_dismissed_version(&mut self, version: &str) -> anyhow::Result<()> {
153 self.update_dismissed_version = Some(version.to_string());
154 self.save()
155 }
156
157 pub fn auto_nav_path_mode(&self) -> flatland_pathfinding::PathMode {
159 self.auto_nav_mode
160 .as_deref()
161 .map(flatland_pathfinding::PathMode::parse)
162 .unwrap_or_default()
163 }
164
165 pub fn save_auto_nav_mode(
166 &mut self,
167 mode: flatland_pathfinding::PathMode,
168 ) -> anyhow::Result<()> {
169 self.auto_nav_mode = Some(mode.as_str().to_string());
170 self.save()
171 }
172
173 pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
174 let host = normalize_host_input(&host.into())?;
175 let default_port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
176 let (host_only, port) = split_host_port(&host, default_port);
177 self.game_host = Some(host_only);
178 if port != DEFAULT_GAME_PORT || self.game_port.is_some() {
179 self.game_port = Some(port);
180 }
181 self.save()
182 }
183
184 pub fn display_game_host(&self) -> String {
186 self.game_host
187 .clone()
188 .unwrap_or_else(|| "127.0.0.1".into())
189 }
190
191 pub fn resolve_game_server_addr(&self) -> anyhow::Result<std::net::SocketAddr> {
193 use std::net::ToSocketAddrs;
194 let host = self.game_host.as_deref().unwrap_or("127.0.0.1");
195 let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
196 let (host, port) = split_host_port(host, port);
197 let target = format!("{host}:{port}");
198 target
199 .to_socket_addrs()
200 .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
201 .next()
202 .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
203 }
204
205 pub fn game_server_addr(&self) -> SocketAddr {
206 self.resolve_game_server_addr().unwrap_or_else(|_| {
207 "127.0.0.1:7373"
208 .parse()
209 .expect("valid default addr")
210 })
211 }
212
213 pub fn api_base_url(&self) -> String {
214 if let Some(host) = self.game_host.as_deref() {
215 let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
216 if host.starts_with("http://") || host.starts_with("https://") {
217 return host.trim_end_matches('/').to_string();
218 }
219 let (host, _) = split_host_port(host, port);
220 return format!("http://{host}:{port}");
221 }
222 "http://127.0.0.1:7380".into()
223 }
224}
225
226pub fn default_game_server_addr() -> SocketAddr {
227 if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
228 if let Ok(parsed) = addr.parse() {
229 return parsed;
230 }
231 if let Ok(resolved) = resolve_game_host_addr(&addr) {
233 return resolved;
234 }
235 }
236 ClientConfig::load().game_server_addr()
237}
238
239pub fn default_api_base_url() -> String {
240 if let Ok(url) = std::env::var("FLATLAND_API_URL") {
241 return url;
242 }
243 ClientConfig::load().api_base_url()
244}
245
246pub fn resolve_game_host_addr(host: &str) -> anyhow::Result<SocketAddr> {
250 use std::net::ToSocketAddrs;
251 let host = normalize_host_input(host)?;
252 let (host, port) = split_host_port(&host, DEFAULT_GAME_PORT);
253 let target = format!("{host}:{port}");
254 target
255 .to_socket_addrs()
256 .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
257 .next()
258 .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
259}
260
261pub fn api_base_url_for_host(host: &str) -> anyhow::Result<String> {
264 let host = normalize_host_input(host)?;
265 if host.starts_with("http://") || host.starts_with("https://") {
266 return Ok(host.trim_end_matches('/').to_string());
267 }
268 let (host, _) = split_host_port(&host, DEFAULT_API_PORT);
269 Ok(format!("http://{host}:{DEFAULT_API_PORT}"))
270}
271
272pub fn apply_host_override(host: &str) -> anyhow::Result<(SocketAddr, String)> {
279 let normalized = normalize_host_input(host)?;
280 let game = resolve_game_host_addr(&normalized)?;
281 let api = api_base_url_for_host(&normalized)?;
282 let (host_only, _) = split_host_port(&normalized, DEFAULT_GAME_PORT);
283
284 std::env::set_var("FLATLAND_HOST_OVERRIDE", &host_only);
285 std::env::set_var("FLATLAND_API_URL", &api);
286 std::env::set_var("FLATLAND_GATEWAY_ADDR", game.to_string());
287
288 if std::env::var_os("FLATLAND_SESSION_PATH").is_none() {
289 if let Some(base) = dirs::config_dir() {
290 let safe: String = host_only
291 .chars()
292 .map(|c| {
293 if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
294 c
295 } else {
296 '_'
297 }
298 })
299 .collect();
300 let path = base.join("flatland").join(format!("session.{safe}.json"));
301 std::env::set_var("FLATLAND_SESSION_PATH", path);
302 }
303 }
304
305 Ok((game, api))
306}
307
308pub fn host_override_active() -> bool {
310 std::env::var_os("FLATLAND_HOST_OVERRIDE").is_some()
311}
312
313pub fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
315 let mut host = raw.trim().to_string();
316 if host.is_empty() {
317 anyhow::bail!("game host cannot be empty");
318 }
319 if let Some(rest) = host.strip_prefix("https://") {
320 host = rest.to_string();
321 } else if let Some(rest) = host.strip_prefix("http://") {
322 host = rest.to_string();
323 }
324 if let Some((h, _)) = host.split_once('/') {
325 host = h.to_string();
326 }
327 host = host.trim_end_matches('/').to_string();
328 if host.is_empty() {
329 anyhow::bail!("game host cannot be empty");
330 }
331 Ok(host)
332}
333
334fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
335 if let Some(rest) = host.strip_prefix('[') {
337 if let Some((addr, port_part)) = rest.split_once("]:") {
338 if let Ok(p) = port_part.parse::<u16>() {
339 return (format!("[{addr}]"), p);
340 }
341 }
342 return (host.to_string(), default_port);
343 }
344 if let Some((h, p)) = host.rsplit_once(':') {
346 if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
347 return (h.to_string(), p.parse().unwrap_or(default_port));
348 }
349 }
350 (host.to_string(), default_port)
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn default_is_localhost() {
359 let cfg = ClientConfig::default();
360 assert_eq!(
361 cfg.game_server_addr(),
362 "127.0.0.1:7373".parse().unwrap()
363 );
364 assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
365 }
366
367 #[test]
368 fn hud_view_roundtrips_in_config() {
369 let mut cfg = ClientConfig::default();
370 cfg.hud_view = Some("map".into());
371 let json = serde_json::to_string(&cfg).unwrap();
372 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
373 assert_eq!(loaded.hud_view.as_deref(), Some("map"));
374 }
375
376 #[test]
377 fn hud_and_workers_prefs_roundtrip() {
378 let mut cfg = ClientConfig::default();
379 cfg.hud_log_hidden = Some(true);
380 cfg.workers_menu_compact = Some(true);
381 cfg.worker_route_panel_collapsed = Some(true);
382 let json = serde_json::to_string(&cfg).unwrap();
383 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
384 assert_eq!(loaded.hud_log_hidden, Some(true));
385 assert_eq!(loaded.workers_menu_compact, Some(true));
386 assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
387 }
388
389 #[test]
390 fn floating_chat_prefs_roundtrip() {
391 let mut cfg = ClientConfig::default();
392 cfg.floating_chat = FloatingChatPrefs {
393 x: Some(42.5),
394 y: Some(100.0),
395 collapsed: Some(true),
396 };
397 let json = serde_json::to_string(&cfg).unwrap();
398 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
399 assert_eq!(loaded.floating_chat.x, Some(42.5));
400 assert_eq!(loaded.floating_chat.y, Some(100.0));
401 assert_eq!(loaded.floating_chat.collapsed, Some(true));
402 }
403
404 #[test]
405 fn split_host_port_handles_dns_and_explicit_port() {
406 assert_eq!(
407 split_host_port("server1.flatland3.com", 7373),
408 ("server1.flatland3.com".into(), 7373)
409 );
410 assert_eq!(
411 split_host_port("server1.flatland3.com:7374", 7373),
412 ("server1.flatland3.com".into(), 7374)
413 );
414 assert_eq!(
415 split_host_port("127.0.0.1", 7373),
416 ("127.0.0.1".into(), 7373)
417 );
418 }
419
420 #[test]
421 fn normalize_strips_scheme() {
422 assert_eq!(
423 normalize_host_input("https://server1.flatland3.com/").unwrap(),
424 "server1.flatland3.com"
425 );
426 }
427
428 #[test]
429 fn api_base_uses_dns_host() {
430 let mut cfg = ClientConfig::default();
431 cfg.game_host = Some("server1.flatland3.com".into());
432 assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
433 }
434
435 #[test]
436 fn api_base_url_for_host_defaults_port() {
437 assert_eq!(
438 api_base_url_for_host("server2.flatland3.com").unwrap(),
439 "http://server2.flatland3.com:7380"
440 );
441 assert_eq!(
442 api_base_url_for_host("server2.flatland3.com:7373").unwrap(),
443 "http://server2.flatland3.com:7380"
444 );
445 }
446}