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 }
202 ClientConfig::load().game_server_addr()
203}
204
205pub fn default_api_base_url() -> String {
206 if let Ok(url) = std::env::var("FLATLAND_API_URL") {
207 return url;
208 }
209 ClientConfig::load().api_base_url()
210}
211
212fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
214 let mut host = raw.trim().to_string();
215 if host.is_empty() {
216 anyhow::bail!("game host cannot be empty");
217 }
218 if let Some(rest) = host.strip_prefix("https://") {
219 host = rest.to_string();
220 } else if let Some(rest) = host.strip_prefix("http://") {
221 host = rest.to_string();
222 }
223 if let Some((h, _)) = host.split_once('/') {
224 host = h.to_string();
225 }
226 host = host.trim_end_matches('/').to_string();
227 if host.is_empty() {
228 anyhow::bail!("game host cannot be empty");
229 }
230 Ok(host)
231}
232
233fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
234 if let Some(rest) = host.strip_prefix('[') {
236 if let Some((addr, port_part)) = rest.split_once("]:") {
237 if let Ok(p) = port_part.parse::<u16>() {
238 return (format!("[{addr}]"), p);
239 }
240 }
241 return (host.to_string(), default_port);
242 }
243 if let Some((h, p)) = host.rsplit_once(':') {
245 if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
246 return (h.to_string(), p.parse().unwrap_or(default_port));
247 }
248 }
249 (host.to_string(), default_port)
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn default_is_localhost() {
258 let cfg = ClientConfig::default();
259 assert_eq!(
260 cfg.game_server_addr(),
261 "127.0.0.1:7373".parse().unwrap()
262 );
263 assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
264 }
265
266 #[test]
267 fn hud_view_roundtrips_in_config() {
268 let mut cfg = ClientConfig::default();
269 cfg.hud_view = Some("map".into());
270 let json = serde_json::to_string(&cfg).unwrap();
271 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
272 assert_eq!(loaded.hud_view.as_deref(), Some("map"));
273 }
274
275 #[test]
276 fn hud_and_workers_prefs_roundtrip() {
277 let mut cfg = ClientConfig::default();
278 cfg.hud_log_hidden = Some(true);
279 cfg.workers_menu_compact = Some(true);
280 cfg.worker_route_panel_collapsed = Some(true);
281 let json = serde_json::to_string(&cfg).unwrap();
282 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
283 assert_eq!(loaded.hud_log_hidden, Some(true));
284 assert_eq!(loaded.workers_menu_compact, Some(true));
285 assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
286 }
287
288 #[test]
289 fn floating_chat_prefs_roundtrip() {
290 let mut cfg = ClientConfig::default();
291 cfg.floating_chat = FloatingChatPrefs {
292 x: Some(42.5),
293 y: Some(100.0),
294 collapsed: Some(true),
295 };
296 let json = serde_json::to_string(&cfg).unwrap();
297 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
298 assert_eq!(loaded.floating_chat.x, Some(42.5));
299 assert_eq!(loaded.floating_chat.y, Some(100.0));
300 assert_eq!(loaded.floating_chat.collapsed, Some(true));
301 }
302
303 #[test]
304 fn split_host_port_handles_dns_and_explicit_port() {
305 assert_eq!(
306 split_host_port("server1.flatland3.com", 7373),
307 ("server1.flatland3.com".into(), 7373)
308 );
309 assert_eq!(
310 split_host_port("server1.flatland3.com:7374", 7373),
311 ("server1.flatland3.com".into(), 7374)
312 );
313 assert_eq!(
314 split_host_port("127.0.0.1", 7373),
315 ("127.0.0.1".into(), 7373)
316 );
317 }
318
319 #[test]
320 fn normalize_strips_scheme() {
321 assert_eq!(
322 normalize_host_input("https://server1.flatland3.com/").unwrap(),
323 "server1.flatland3.com"
324 );
325 }
326
327 #[test]
328 fn api_base_uses_dns_host() {
329 let mut cfg = ClientConfig::default();
330 cfg.game_host = Some("server1.flatland3.com".into());
331 assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
332 }
333}