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