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}
83
84impl GfxWindowPrefs {
85 pub fn is_empty(&self) -> bool {
86 self.width.is_none()
87 && self.height.is_none()
88 && self.x.is_none()
89 && 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 set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
155 let host = normalize_host_input(&host.into())?;
156 let default_port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
157 let (host_only, port) = split_host_port(&host, default_port);
158 self.game_host = Some(host_only);
159 if port != DEFAULT_GAME_PORT || self.game_port.is_some() {
160 self.game_port = Some(port);
161 }
162 self.save()
163 }
164
165 pub fn display_game_host(&self) -> String {
167 self.game_host
168 .clone()
169 .unwrap_or_else(|| "127.0.0.1".into())
170 }
171
172 pub fn resolve_game_server_addr(&self) -> anyhow::Result<std::net::SocketAddr> {
174 use std::net::ToSocketAddrs;
175 let host = self.game_host.as_deref().unwrap_or("127.0.0.1");
176 let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
177 let (host, port) = split_host_port(host, port);
178 let target = format!("{host}:{port}");
179 target
180 .to_socket_addrs()
181 .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
182 .next()
183 .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
184 }
185
186 pub fn game_server_addr(&self) -> SocketAddr {
187 self.resolve_game_server_addr().unwrap_or_else(|_| {
188 "127.0.0.1:7373"
189 .parse()
190 .expect("valid default addr")
191 })
192 }
193
194 pub fn api_base_url(&self) -> String {
195 if let Some(host) = self.game_host.as_deref() {
196 let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
197 if host.starts_with("http://") || host.starts_with("https://") {
198 return host.trim_end_matches('/').to_string();
199 }
200 let (host, _) = split_host_port(host, port);
201 return format!("http://{host}:{port}");
202 }
203 "http://127.0.0.1:7380".into()
204 }
205}
206
207pub fn default_game_server_addr() -> SocketAddr {
208 if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
209 if let Ok(parsed) = addr.parse() {
210 return parsed;
211 }
212 if let Ok(resolved) = resolve_game_host_addr(&addr) {
214 return resolved;
215 }
216 }
217 ClientConfig::load().game_server_addr()
218}
219
220pub fn default_api_base_url() -> String {
221 if let Ok(url) = std::env::var("FLATLAND_API_URL") {
222 return url;
223 }
224 ClientConfig::load().api_base_url()
225}
226
227pub fn resolve_game_host_addr(host: &str) -> anyhow::Result<SocketAddr> {
231 use std::net::ToSocketAddrs;
232 let host = normalize_host_input(host)?;
233 let (host, port) = split_host_port(&host, DEFAULT_GAME_PORT);
234 let target = format!("{host}:{port}");
235 target
236 .to_socket_addrs()
237 .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
238 .next()
239 .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
240}
241
242pub fn api_base_url_for_host(host: &str) -> anyhow::Result<String> {
245 let host = normalize_host_input(host)?;
246 if host.starts_with("http://") || host.starts_with("https://") {
247 return Ok(host.trim_end_matches('/').to_string());
248 }
249 let (host, _) = split_host_port(&host, DEFAULT_API_PORT);
250 Ok(format!("http://{host}:{DEFAULT_API_PORT}"))
251}
252
253pub fn apply_host_override(host: &str) -> anyhow::Result<(SocketAddr, String)> {
260 let normalized = normalize_host_input(host)?;
261 let game = resolve_game_host_addr(&normalized)?;
262 let api = api_base_url_for_host(&normalized)?;
263 let (host_only, _) = split_host_port(&normalized, DEFAULT_GAME_PORT);
264
265 std::env::set_var("FLATLAND_HOST_OVERRIDE", &host_only);
266 std::env::set_var("FLATLAND_API_URL", &api);
267 std::env::set_var("FLATLAND_GATEWAY_ADDR", game.to_string());
268
269 if std::env::var_os("FLATLAND_SESSION_PATH").is_none() {
270 if let Some(base) = dirs::config_dir() {
271 let safe: String = host_only
272 .chars()
273 .map(|c| {
274 if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
275 c
276 } else {
277 '_'
278 }
279 })
280 .collect();
281 let path = base.join("flatland").join(format!("session.{safe}.json"));
282 std::env::set_var("FLATLAND_SESSION_PATH", path);
283 }
284 }
285
286 Ok((game, api))
287}
288
289pub fn host_override_active() -> bool {
291 std::env::var_os("FLATLAND_HOST_OVERRIDE").is_some()
292}
293
294pub fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
296 let mut host = raw.trim().to_string();
297 if host.is_empty() {
298 anyhow::bail!("game host cannot be empty");
299 }
300 if let Some(rest) = host.strip_prefix("https://") {
301 host = rest.to_string();
302 } else if let Some(rest) = host.strip_prefix("http://") {
303 host = rest.to_string();
304 }
305 if let Some((h, _)) = host.split_once('/') {
306 host = h.to_string();
307 }
308 host = host.trim_end_matches('/').to_string();
309 if host.is_empty() {
310 anyhow::bail!("game host cannot be empty");
311 }
312 Ok(host)
313}
314
315fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
316 if let Some(rest) = host.strip_prefix('[') {
318 if let Some((addr, port_part)) = rest.split_once("]:") {
319 if let Ok(p) = port_part.parse::<u16>() {
320 return (format!("[{addr}]"), p);
321 }
322 }
323 return (host.to_string(), default_port);
324 }
325 if let Some((h, p)) = host.rsplit_once(':') {
327 if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
328 return (h.to_string(), p.parse().unwrap_or(default_port));
329 }
330 }
331 (host.to_string(), default_port)
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn default_is_localhost() {
340 let cfg = ClientConfig::default();
341 assert_eq!(
342 cfg.game_server_addr(),
343 "127.0.0.1:7373".parse().unwrap()
344 );
345 assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
346 }
347
348 #[test]
349 fn hud_view_roundtrips_in_config() {
350 let mut cfg = ClientConfig::default();
351 cfg.hud_view = Some("map".into());
352 let json = serde_json::to_string(&cfg).unwrap();
353 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
354 assert_eq!(loaded.hud_view.as_deref(), Some("map"));
355 }
356
357 #[test]
358 fn hud_and_workers_prefs_roundtrip() {
359 let mut cfg = ClientConfig::default();
360 cfg.hud_log_hidden = Some(true);
361 cfg.workers_menu_compact = Some(true);
362 cfg.worker_route_panel_collapsed = Some(true);
363 let json = serde_json::to_string(&cfg).unwrap();
364 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
365 assert_eq!(loaded.hud_log_hidden, Some(true));
366 assert_eq!(loaded.workers_menu_compact, Some(true));
367 assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
368 }
369
370 #[test]
371 fn floating_chat_prefs_roundtrip() {
372 let mut cfg = ClientConfig::default();
373 cfg.floating_chat = FloatingChatPrefs {
374 x: Some(42.5),
375 y: Some(100.0),
376 collapsed: Some(true),
377 };
378 let json = serde_json::to_string(&cfg).unwrap();
379 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
380 assert_eq!(loaded.floating_chat.x, Some(42.5));
381 assert_eq!(loaded.floating_chat.y, Some(100.0));
382 assert_eq!(loaded.floating_chat.collapsed, Some(true));
383 }
384
385 #[test]
386 fn split_host_port_handles_dns_and_explicit_port() {
387 assert_eq!(
388 split_host_port("server1.flatland3.com", 7373),
389 ("server1.flatland3.com".into(), 7373)
390 );
391 assert_eq!(
392 split_host_port("server1.flatland3.com:7374", 7373),
393 ("server1.flatland3.com".into(), 7374)
394 );
395 assert_eq!(
396 split_host_port("127.0.0.1", 7373),
397 ("127.0.0.1".into(), 7373)
398 );
399 }
400
401 #[test]
402 fn normalize_strips_scheme() {
403 assert_eq!(
404 normalize_host_input("https://server1.flatland3.com/").unwrap(),
405 "server1.flatland3.com"
406 );
407 }
408
409 #[test]
410 fn api_base_uses_dns_host() {
411 let mut cfg = ClientConfig::default();
412 cfg.game_host = Some("server1.flatland3.com".into());
413 assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
414 }
415
416 #[test]
417 fn api_base_url_for_host_defaults_port() {
418 assert_eq!(
419 api_base_url_for_host("server2.flatland3.com").unwrap(),
420 "http://server2.flatland3.com:7380"
421 );
422 assert_eq!(
423 api_base_url_for_host("server2.flatland3.com:7373").unwrap(),
424 "http://server2.flatland3.com:7380"
425 );
426 }
427}