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, PartialEq)]
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, PartialEq)]
pub struct FloatingChatPrefs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collapsed: Option<bool>,
}
impl FloatingChatPrefs {
pub fn is_empty(&self) -> bool {
self.x.is_none() && self.y.is_none() && self.collapsed.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct FloatingPanelPrefs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collapsed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
}
impl FloatingPanelPrefs {
pub fn is_empty(&self) -> bool {
self.x.is_none()
&& self.y.is_none()
&& self.collapsed.is_none()
&& self.hidden.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FloatingPanelId {
Chat = 1,
Quest = 2,
Location = 3,
Log = 4,
Stats = 5,
}
impl FloatingPanelId {
pub const ALL: [Self; 5] = [
Self::Chat,
Self::Quest,
Self::Location,
Self::Log,
Self::Stats,
];
pub fn from_toggle_slot(slot: u8) -> Option<Self> {
match slot {
1 => Some(Self::Chat),
2 => Some(Self::Quest),
3 => Some(Self::Location),
4 => Some(Self::Log),
5 => Some(Self::Stats),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Quest => "quest",
Self::Location => "location",
Self::Log => "log",
Self::Stats => "stats",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct FloatingPanelsPrefs {
#[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
pub chat: FloatingPanelPrefs,
#[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
pub quest: FloatingPanelPrefs,
#[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
pub location: FloatingPanelPrefs,
#[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
pub log: FloatingPanelPrefs,
#[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
pub stats: FloatingPanelPrefs,
}
impl FloatingPanelsPrefs {
pub fn is_empty(&self) -> bool {
self.chat.is_empty()
&& self.quest.is_empty()
&& self.location.is_empty()
&& self.log.is_empty()
&& self.stats.is_empty()
}
pub fn panel(&self, id: FloatingPanelId) -> &FloatingPanelPrefs {
match id {
FloatingPanelId::Chat => &self.chat,
FloatingPanelId::Quest => &self.quest,
FloatingPanelId::Location => &self.location,
FloatingPanelId::Log => &self.log,
FloatingPanelId::Stats => &self.stats,
}
}
pub fn panel_mut(&mut self, id: FloatingPanelId) -> &mut FloatingPanelPrefs {
match id {
FloatingPanelId::Chat => &mut self.chat,
FloatingPanelId::Quest => &mut self.quest,
FloatingPanelId::Location => &mut self.location,
FloatingPanelId::Log => &mut self.log,
FloatingPanelId::Stats => &mut self.stats,
}
}
}
#[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 = "FloatingChatPrefs::is_empty")]
pub floating_chat: FloatingChatPrefs,
#[serde(default, skip_serializing_if = "FloatingPanelsPrefs::is_empty")]
pub floating_panels: FloatingPanelsPrefs,
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check_updates: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_dismissed_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_nav_mode: Option<String>,
}
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 {
let mut cfg: Self = Self::path()
.ok()
.and_then(|path| std::fs::read(&path).ok())
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
cfg.migrate_floating_panels();
cfg
}
pub fn migrate_floating_panels(&mut self) {
if !self.floating_chat.is_empty() && self.floating_panels.chat.is_empty() {
self.floating_panels.chat = FloatingPanelPrefs {
x: self.floating_chat.x,
y: self.floating_chat.y,
collapsed: self.floating_chat.collapsed,
hidden: None,
};
}
if self.hud_log_hidden == Some(true) && self.floating_panels.log.hidden.is_none() {
self.floating_panels.log.hidden = Some(true);
}
}
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_floating_chat(&mut self, prefs: FloatingChatPrefs) -> anyhow::Result<()> {
self.floating_chat = prefs;
self.save()
}
pub fn save_floating_panel(
&mut self,
id: FloatingPanelId,
prefs: FloatingPanelPrefs,
) -> anyhow::Result<()> {
*self.floating_panels.panel_mut(id) = 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 save_update_dismissed_version(&mut self, version: &str) -> anyhow::Result<()> {
self.update_dismissed_version = Some(version.to_string());
self.save()
}
pub fn auto_nav_path_mode(&self) -> flatland_pathfinding::PathMode {
self.auto_nav_mode
.as_deref()
.map(flatland_pathfinding::PathMode::parse)
.unwrap_or_default()
}
pub fn save_auto_nav_mode(
&mut self,
mode: flatland_pathfinding::PathMode,
) -> anyhow::Result<()> {
self.auto_nav_mode = Some(mode.as_str().to_string());
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;
}
if let Ok(resolved) = resolve_game_host_addr(&addr) {
return resolved;
}
}
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()
}
pub fn resolve_game_host_addr(host: &str) -> anyhow::Result<SocketAddr> {
use std::net::ToSocketAddrs;
let host = normalize_host_input(host)?;
let (host, port) = split_host_port(&host, DEFAULT_GAME_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 api_base_url_for_host(host: &str) -> anyhow::Result<String> {
let host = normalize_host_input(host)?;
if host.starts_with("http://") || host.starts_with("https://") {
return Ok(host.trim_end_matches('/').to_string());
}
let (host, _) = split_host_port(&host, DEFAULT_API_PORT);
Ok(format!("http://{host}:{DEFAULT_API_PORT}"))
}
pub fn apply_host_override(host: &str) -> anyhow::Result<(SocketAddr, String)> {
let normalized = normalize_host_input(host)?;
let game = resolve_game_host_addr(&normalized)?;
let api = api_base_url_for_host(&normalized)?;
let (host_only, _) = split_host_port(&normalized, DEFAULT_GAME_PORT);
std::env::set_var("FLATLAND_HOST_OVERRIDE", &host_only);
std::env::set_var("FLATLAND_API_URL", &api);
std::env::set_var("FLATLAND_GATEWAY_ADDR", game.to_string());
if std::env::var_os("FLATLAND_SESSION_PATH").is_none() {
if let Some(base) = dirs::config_dir() {
let safe: String = host_only
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
c
} else {
'_'
}
})
.collect();
let path = base.join("flatland").join(format!("session.{safe}.json"));
std::env::set_var("FLATLAND_SESSION_PATH", path);
}
}
Ok((game, api))
}
pub fn host_override_active() -> bool {
std::env::var_os("FLATLAND_HOST_OVERRIDE").is_some()
}
pub 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 floating_chat_prefs_roundtrip() {
let mut cfg = ClientConfig::default();
cfg.floating_chat = FloatingChatPrefs {
x: Some(42.5),
y: Some(100.0),
collapsed: Some(true),
};
let json = serde_json::to_string(&cfg).unwrap();
let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.floating_chat.x, Some(42.5));
assert_eq!(loaded.floating_chat.y, Some(100.0));
assert_eq!(loaded.floating_chat.collapsed, Some(true));
}
#[test]
fn floating_panels_prefs_roundtrip() {
let mut cfg = ClientConfig::default();
cfg.floating_panels.log = FloatingPanelPrefs {
x: Some(10.0),
y: Some(20.0),
collapsed: Some(false),
hidden: Some(true),
};
let json = serde_json::to_string(&cfg).unwrap();
let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.floating_panels.log.hidden, Some(true));
assert_eq!(loaded.floating_panels.log.x, Some(10.0));
}
#[test]
fn migrates_legacy_floating_chat_and_log_hidden() {
let mut cfg = ClientConfig::default();
cfg.floating_chat = FloatingChatPrefs {
x: Some(1.0),
y: Some(2.0),
collapsed: Some(true),
};
cfg.hud_log_hidden = Some(true);
cfg.migrate_floating_panels();
assert_eq!(cfg.floating_panels.chat.x, Some(1.0));
assert_eq!(cfg.floating_panels.chat.collapsed, Some(true));
assert_eq!(cfg.floating_panels.log.hidden, 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");
}
#[test]
fn api_base_url_for_host_defaults_port() {
assert_eq!(
api_base_url_for_host("server2.flatland3.com").unwrap(),
"http://server2.flatland3.com:7380"
);
assert_eq!(
api_base_url_for_host("server2.flatland3.com:7373").unwrap(),
"http://server2.flatland3.com:7380"
);
}
}