flatland_client_lib/
client_config.rs1use 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)]
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)]
28pub struct ClientConfig {
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub game_host: Option<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub api_port: Option<u16>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub game_port: Option<u16>,
38 #[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
40 pub gfx_window: GfxWindowPrefs,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub hud_view: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub hud_log_hidden: Option<bool>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub workers_menu_compact: Option<bool>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub worker_route_panel_collapsed: Option<bool>,
53}
54
55impl GfxWindowPrefs {
56 pub fn is_empty(&self) -> bool {
57 self.width.is_none()
58 && self.height.is_none()
59 && self.x.is_none()
60 && self.y.is_none()
61 }
62}
63
64impl ClientConfig {
65 pub fn path() -> anyhow::Result<PathBuf> {
66 let base = dirs::config_dir()
67 .ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
68 Ok(base.join("flatland").join("client.json"))
69 }
70
71 pub fn load() -> Self {
72 Self::path()
73 .ok()
74 .and_then(|path| std::fs::read(&path).ok())
75 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
76 .unwrap_or_default()
77 }
78
79 pub fn save(&self) -> anyhow::Result<()> {
80 let path = Self::path()?;
81 if let Some(parent) = path.parent() {
82 std::fs::create_dir_all(parent)?;
83 }
84 std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
85 Ok(())
86 }
87
88 pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
89 self.gfx_window = prefs;
90 self.save()
91 }
92
93 pub fn save_hud_view(&mut self, label: &str) -> anyhow::Result<()> {
95 self.hud_view = Some(label.to_string());
96 self.save()
97 }
98
99 pub fn save_hud_log_hidden(&mut self, hidden: bool) -> anyhow::Result<()> {
100 self.hud_log_hidden = Some(hidden);
101 self.save()
102 }
103
104 pub fn save_workers_menu_compact(&mut self, compact: bool) -> anyhow::Result<()> {
105 self.workers_menu_compact = Some(compact);
106 self.save()
107 }
108
109 pub fn save_worker_route_panel_collapsed(&mut self, collapsed: bool) -> anyhow::Result<()> {
110 self.worker_route_panel_collapsed = Some(collapsed);
111 self.save()
112 }
113
114 pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
115 let host = normalize_host_input(&host.into())?;
116 let default_port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
117 let (host_only, port) = split_host_port(&host, default_port);
118 self.game_host = Some(host_only);
119 if port != DEFAULT_GAME_PORT || self.game_port.is_some() {
120 self.game_port = Some(port);
121 }
122 self.save()
123 }
124
125 pub fn display_game_host(&self) -> String {
127 self.game_host
128 .clone()
129 .unwrap_or_else(|| "127.0.0.1".into())
130 }
131
132 pub fn resolve_game_server_addr(&self) -> anyhow::Result<std::net::SocketAddr> {
134 use std::net::ToSocketAddrs;
135 let host = self.game_host.as_deref().unwrap_or("127.0.0.1");
136 let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
137 let (host, port) = split_host_port(host, port);
138 let target = format!("{host}:{port}");
139 target
140 .to_socket_addrs()
141 .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
142 .next()
143 .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
144 }
145
146 pub fn game_server_addr(&self) -> SocketAddr {
147 self.resolve_game_server_addr().unwrap_or_else(|_| {
148 "127.0.0.1:7373"
149 .parse()
150 .expect("valid default addr")
151 })
152 }
153
154 pub fn api_base_url(&self) -> String {
155 if let Some(host) = self.game_host.as_deref() {
156 let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
157 if host.starts_with("http://") || host.starts_with("https://") {
158 return host.trim_end_matches('/').to_string();
159 }
160 let (host, _) = split_host_port(host, port);
161 return format!("http://{host}:{port}");
162 }
163 "http://127.0.0.1:7380".into()
164 }
165}
166
167pub fn default_game_server_addr() -> SocketAddr {
168 if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
169 if let Ok(parsed) = addr.parse() {
170 return parsed;
171 }
172 }
173 ClientConfig::load().game_server_addr()
174}
175
176pub fn default_api_base_url() -> String {
177 if let Ok(url) = std::env::var("FLATLAND_API_URL") {
178 return url;
179 }
180 ClientConfig::load().api_base_url()
181}
182
183fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
185 let mut host = raw.trim().to_string();
186 if host.is_empty() {
187 anyhow::bail!("game host cannot be empty");
188 }
189 if let Some(rest) = host.strip_prefix("https://") {
190 host = rest.to_string();
191 } else if let Some(rest) = host.strip_prefix("http://") {
192 host = rest.to_string();
193 }
194 if let Some((h, _)) = host.split_once('/') {
195 host = h.to_string();
196 }
197 host = host.trim_end_matches('/').to_string();
198 if host.is_empty() {
199 anyhow::bail!("game host cannot be empty");
200 }
201 Ok(host)
202}
203
204fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
205 if let Some(rest) = host.strip_prefix('[') {
207 if let Some((addr, port_part)) = rest.split_once("]:") {
208 if let Ok(p) = port_part.parse::<u16>() {
209 return (format!("[{addr}]"), p);
210 }
211 }
212 return (host.to_string(), default_port);
213 }
214 if let Some((h, p)) = host.rsplit_once(':') {
216 if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
217 return (h.to_string(), p.parse().unwrap_or(default_port));
218 }
219 }
220 (host.to_string(), default_port)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn default_is_localhost() {
229 let cfg = ClientConfig::default();
230 assert_eq!(
231 cfg.game_server_addr(),
232 "127.0.0.1:7373".parse().unwrap()
233 );
234 assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
235 }
236
237 #[test]
238 fn hud_view_roundtrips_in_config() {
239 let mut cfg = ClientConfig::default();
240 cfg.hud_view = Some("map".into());
241 let json = serde_json::to_string(&cfg).unwrap();
242 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
243 assert_eq!(loaded.hud_view.as_deref(), Some("map"));
244 }
245
246 #[test]
247 fn hud_and_workers_prefs_roundtrip() {
248 let mut cfg = ClientConfig::default();
249 cfg.hud_log_hidden = Some(true);
250 cfg.workers_menu_compact = Some(true);
251 cfg.worker_route_panel_collapsed = Some(true);
252 let json = serde_json::to_string(&cfg).unwrap();
253 let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
254 assert_eq!(loaded.hud_log_hidden, Some(true));
255 assert_eq!(loaded.workers_menu_compact, Some(true));
256 assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
257 }
258
259 #[test]
260 fn split_host_port_handles_dns_and_explicit_port() {
261 assert_eq!(
262 split_host_port("server1.flatland3.com", 7373),
263 ("server1.flatland3.com".into(), 7373)
264 );
265 assert_eq!(
266 split_host_port("server1.flatland3.com:7374", 7373),
267 ("server1.flatland3.com".into(), 7374)
268 );
269 assert_eq!(
270 split_host_port("127.0.0.1", 7373),
271 ("127.0.0.1".into(), 7373)
272 );
273 }
274
275 #[test]
276 fn normalize_strips_scheme() {
277 assert_eq!(
278 normalize_host_input("https://server1.flatland3.com/").unwrap(),
279 "server1.flatland3.com"
280 );
281 }
282
283 #[test]
284 fn api_base_uses_dns_host() {
285 let mut cfg = ClientConfig::default();
286 cfg.game_host = Some("server1.flatland3.com".into());
287 assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
288 }
289}