use std::net::SocketAddr;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const DEFAULT_GAME_PORT: u16 = 7373;
pub const DEFAULT_API_PORT: u16 = 7380;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GfxWindowPrefs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub game_host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub game_port: Option<u16>,
#[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
pub gfx_window: GfxWindowPrefs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hud_view: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hud_log_hidden: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workers_menu_compact: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_route_panel_collapsed: Option<bool>,
}
impl GfxWindowPrefs {
pub fn is_empty(&self) -> bool {
self.width.is_none()
&& self.height.is_none()
&& self.x.is_none()
&& self.y.is_none()
}
}
impl ClientConfig {
pub fn path() -> anyhow::Result<PathBuf> {
let base = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
Ok(base.join("flatland").join("client.json"))
}
pub fn load() -> Self {
Self::path()
.ok()
.and_then(|path| std::fs::read(&path).ok())
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
Ok(())
}
pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
self.gfx_window = prefs;
self.save()
}
pub fn save_hud_view(&mut self, label: &str) -> anyhow::Result<()> {
self.hud_view = Some(label.to_string());
self.save()
}
pub fn save_hud_log_hidden(&mut self, hidden: bool) -> anyhow::Result<()> {
self.hud_log_hidden = Some(hidden);
self.save()
}
pub fn save_workers_menu_compact(&mut self, compact: bool) -> anyhow::Result<()> {
self.workers_menu_compact = Some(compact);
self.save()
}
pub fn save_worker_route_panel_collapsed(&mut self, collapsed: bool) -> anyhow::Result<()> {
self.worker_route_panel_collapsed = Some(collapsed);
self.save()
}
pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
let host = normalize_host_input(&host.into())?;
let default_port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
let (host_only, port) = split_host_port(&host, default_port);
self.game_host = Some(host_only);
if port != DEFAULT_GAME_PORT || self.game_port.is_some() {
self.game_port = Some(port);
}
self.save()
}
pub fn display_game_host(&self) -> String {
self.game_host
.clone()
.unwrap_or_else(|| "127.0.0.1".into())
}
pub fn resolve_game_server_addr(&self) -> anyhow::Result<std::net::SocketAddr> {
use std::net::ToSocketAddrs;
let host = self.game_host.as_deref().unwrap_or("127.0.0.1");
let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
let (host, port) = split_host_port(host, port);
let target = format!("{host}:{port}");
target
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
}
pub fn game_server_addr(&self) -> SocketAddr {
self.resolve_game_server_addr().unwrap_or_else(|_| {
"127.0.0.1:7373"
.parse()
.expect("valid default addr")
})
}
pub fn api_base_url(&self) -> String {
if let Some(host) = self.game_host.as_deref() {
let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
if host.starts_with("http://") || host.starts_with("https://") {
return host.trim_end_matches('/').to_string();
}
let (host, _) = split_host_port(host, port);
return format!("http://{host}:{port}");
}
"http://127.0.0.1:7380".into()
}
}
pub fn default_game_server_addr() -> SocketAddr {
if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
if let Ok(parsed) = addr.parse() {
return parsed;
}
}
ClientConfig::load().game_server_addr()
}
pub fn default_api_base_url() -> String {
if let Ok(url) = std::env::var("FLATLAND_API_URL") {
return url;
}
ClientConfig::load().api_base_url()
}
fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
let mut host = raw.trim().to_string();
if host.is_empty() {
anyhow::bail!("game host cannot be empty");
}
if let Some(rest) = host.strip_prefix("https://") {
host = rest.to_string();
} else if let Some(rest) = host.strip_prefix("http://") {
host = rest.to_string();
}
if let Some((h, _)) = host.split_once('/') {
host = h.to_string();
}
host = host.trim_end_matches('/').to_string();
if host.is_empty() {
anyhow::bail!("game host cannot be empty");
}
Ok(host)
}
fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
if let Some(rest) = host.strip_prefix('[') {
if let Some((addr, port_part)) = rest.split_once("]:") {
if let Ok(p) = port_part.parse::<u16>() {
return (format!("[{addr}]"), p);
}
}
return (host.to_string(), default_port);
}
if let Some((h, p)) = host.rsplit_once(':') {
if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
return (h.to_string(), p.parse().unwrap_or(default_port));
}
}
(host.to_string(), default_port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_localhost() {
let cfg = ClientConfig::default();
assert_eq!(
cfg.game_server_addr(),
"127.0.0.1:7373".parse().unwrap()
);
assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
}
#[test]
fn hud_view_roundtrips_in_config() {
let mut cfg = ClientConfig::default();
cfg.hud_view = Some("map".into());
let json = serde_json::to_string(&cfg).unwrap();
let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.hud_view.as_deref(), Some("map"));
}
#[test]
fn hud_and_workers_prefs_roundtrip() {
let mut cfg = ClientConfig::default();
cfg.hud_log_hidden = Some(true);
cfg.workers_menu_compact = Some(true);
cfg.worker_route_panel_collapsed = Some(true);
let json = serde_json::to_string(&cfg).unwrap();
let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.hud_log_hidden, Some(true));
assert_eq!(loaded.workers_menu_compact, Some(true));
assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
}
#[test]
fn split_host_port_handles_dns_and_explicit_port() {
assert_eq!(
split_host_port("server1.flatland3.com", 7373),
("server1.flatland3.com".into(), 7373)
);
assert_eq!(
split_host_port("server1.flatland3.com:7374", 7373),
("server1.flatland3.com".into(), 7374)
);
assert_eq!(
split_host_port("127.0.0.1", 7373),
("127.0.0.1".into(), 7373)
);
}
#[test]
fn normalize_strips_scheme() {
assert_eq!(
normalize_host_input("https://server1.flatland3.com/").unwrap(),
"server1.flatland3.com"
);
}
#[test]
fn api_base_uses_dns_host() {
let mut cfg = ClientConfig::default();
cfg.game_host = Some("server1.flatland3.com".into());
assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
}
}