## ServerConfig — CFG/JSON Dual-Format Config Manager ## ## Autoload singleton that loads, validates, and exposes server configuration. ## ## ## Architecture ## ## Load chain: ## 1. SERVER_CFG env var → absolute path to override .cfg ## 2. user://server_config.cfg (writable, operator-edited) ## 3. res://config/default_server_config.cfg (bundled, read-only) ## ## Exports: ## - Typed GDScript properties (this script) ## - JSON file written alongside the loaded .cfg for automation ## - ConfigChanged signal for hot-reload (future) ## ## ## Dual Format ## ## Humans edit `.cfg` (INI-style, Godot ConfigFile parser). ## Automation reads `.json` (written on every load/save). ## ## File: server_config.cfg ← human editor ## Mirror: server_config.json ← machine consumer (written atomically) ## ## ## Sections ## ## [server] — Network bind, port, name, password, tick_rate ## [game] — Round timers, gravity, friendly fire, respawn ## [movement] — Walk/sprint/crouch speeds, acceleration, friction ## [match] — Win limit, time limit, map rotation ## [rcon] — Remote console (future) ## [logging] — Log level and file path ## [teams] — Team count and size ## ## ## Extension Pattern ## ## ServerMain uses ServerConfig at startup. Each subsystem accesses ## the singleton directly — no pass-through boilerplate: ## ## ServerConfig.port # int ## ServerConfig.server_name # String ## ServerConfig.tick_rate # int ## ServerConfig.movement_walk_speed # float ## ServerConfig.map_list # Array[String] ## ServerConfig.to_json_string() # String ## ## ============================================================================= extends Node # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when the config is (re)loaded. Subsystems that cache config ## values should reconnect to this signal to pick up live changes. signal config_loaded() # --------------------------------------------------------------------------- # Section: [server] # --------------------------------------------------------------------------- var server_name: String = "Tactical Shooter Server" var description: String = "" var bind_ip: String = "0.0.0.0" var port: int = 34197 var max_players: int = 16 var password: String = "" var tick_rate: int = 128 # --------------------------------------------------------------------------- # Section: [game] # --------------------------------------------------------------------------- var round_time_seconds: int = 600 var warmup_time_seconds: int = 60 var friendly_fire: bool = false var ff_damage_multiplier: float = 0.5 var gravity: float = -24.0 var respawn_time_seconds: float = 5.0 var spectate_enabled: bool = true # --------------------------------------------------------------------------- # Section: [movement] # --------------------------------------------------------------------------- var movement_walk_speed: float = 5.0 var movement_sprint_speed: float = 7.0 var movement_crouch_speed: float = 2.0 var movement_jump_velocity: float = 6.0 var movement_acceleration: float = 20.0 var movement_air_acceleration: float = 4.0 var movement_friction: float = 8.0 # --------------------------------------------------------------------------- # Section: [match] # --------------------------------------------------------------------------- var win_limit: int = 3 var time_limit_seconds: int = 0 var map_rotation_mode: String = "sequence" # "sequence" | "vote" | "random" var maps: String = "test_range" # comma-separated ## Parsed map list (populated after load). var map_list: Array[String] = [] # --------------------------------------------------------------------------- # Section: [rcon] # --------------------------------------------------------------------------- var rcon_enabled: bool = false var rcon_password: String = "" var rcon_port: int = 34198 # --------------------------------------------------------------------------- # Section: [logging] # --------------------------------------------------------------------------- var log_level: String = "info" var log_file: String = "" # --------------------------------------------------------------------------- # Section: [teams] # --------------------------------------------------------------------------- var team_count: int = 2 var players_per_team: int = 8 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- ## Absolute path to the loaded .cfg file (user override or bundled default). var config_path: String = "" : get = get_config_path ## Absolute path to the mirrored .json file. var json_path: String = "" : get = get_json_path var _loaded: bool = false var _config_path_resolved: String = "" var _json_path_resolved: String = "" # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _load_config() # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- ## Reload the config from disk. Emits config_loaded on success. func reload() -> bool: return _load_config() ## Return the entire config as a formatted JSON string. func to_json_string() -> String: var data := _build_json_dict() return JSON.stringify(data, "\t", false) ## Return the entire config as a Godot Dictionary (same shape as JSON). func to_dict() -> Dictionary: return _build_json_dict() ## Return the movement config as a Dictionary (for SimulationServer.set_movement_config()). func make_movement_dict() -> Dictionary: return { walk_speed = movement_walk_speed, sprint_speed = movement_sprint_speed, crouch_speed = movement_crouch_speed, acceleration = movement_acceleration, air_acceleration = movement_air_acceleration, friction = movement_friction, jump_velocity = movement_jump_velocity, gravity = gravity, } ## Write the current config state to the JSON mirror file. ## Does NOT touch the .cfg (only humans edit the .cfg). func save_json() -> Error: if _json_path_resolved.is_empty(): return ERR_FILE_BAD_PATH var f := FileAccess.open(_json_path_resolved, FileAccess.WRITE) if f == null: push_error("[ServerConfig] Failed to write JSON mirror: %s" % _json_path_resolved) return FileAccess.get_open_error() f.store_string(to_json_string()) f.close() return OK ## Write the current config state to the .cfg file (serialises all sections). ## Returns OK on success, ERR_* on failure. func save_cfg() -> Error: var cfg := ConfigFile.new() _populate_cfg(cfg) var path := _config_path_resolved if not _config_path_resolved.is_empty() else "user://server_config.cfg" var err := cfg.save(path) if err != OK: push_error("[ServerConfig] Failed to save config: %s" % error_string(err)) return err # Re-init from file to ensure in-memory state matches _load_from_path(cfg, _config_path_resolved) return OK # --------------------------------------------------------------------------- # Internal: Loading # --------------------------------------------------------------------------- func _load_config() -> bool: # Determine config source path var cfg_path := _resolve_source_path() if cfg_path.is_empty(): push_error("[ServerConfig] No config file could be resolved.") return false var cfg := ConfigFile.new() var err := cfg.load(cfg_path) if err != OK: push_error("[ServerConfig] Failed to load config: %s (error %d)" % [cfg_path, err]) return false _load_from_path(cfg, cfg_path) # Write JSON mirror save_json() _loaded = true config_loaded.emit() print("[ServerConfig] Loaded config: %s (%d keys)" % [cfg_path, cfg.get_sections().size() * 8]) return true ## Path resolution order: ## 1. SERVER_CFG env var (absolute path) ## 2. user://server_config.cfg (writable operator override) ## 3. res://config/default_server_config.cfg (bundled default) func _resolve_source_path() -> String: # Env var override if OS.has_environment("SERVER_CFG"): var env_path := OS.get_environment("SERVER_CFG") if FileAccess.file_exists(env_path): _config_path_resolved = env_path _json_path_resolved = env_path.get_basename() + ".json" return env_path else: push_warning("[ServerConfig] SERVER_CFG path does not exist: %s — falling back" % env_path) # User override var user_path := ProjectSettings.globalize_path("user://server_config.cfg") if FileAccess.file_exists(user_path): _config_path_resolved = user_path _json_path_resolved = user_path.get_basename() + ".json" return user_path # Bundled default — convert to absolute path for JSON mirror var res_path := ProjectSettings.globalize_path("res://config/default_server_config.cfg") _config_path_resolved = res_path _json_path_resolved = res_path.get_basename() + ".json" # First run: copy bundled default to user:// so operators can edit it var user_dir := ProjectSettings.globalize_path("user://") var copy_path := user_dir.path_join("server_config.cfg") if not FileAccess.file_exists(copy_path): var src := FileAccess.open(res_path, FileAccess.READ) if src: var dst := FileAccess.open(copy_path, FileAccess.WRITE) if dst: dst.store_string(src.get_as_text()) dst.close() print("[ServerConfig] First run — copied default config to: %s" % copy_path) _config_path_resolved = copy_path _json_path_resolved = copy_path.get_basename() + ".json" return copy_path src.close() return res_path func _load_from_path(cfg: ConfigFile, path: String) -> void: _config_path_resolved = path _json_path_resolved = path.get_basename() + ".json" # [server] server_name = cfg.get_value("server", "server_name", server_name) description = cfg.get_value("server", "description", description) bind_ip = cfg.get_value("server", "bind_ip", bind_ip) port = cfg.get_value("server", "port", port) max_players = cfg.get_value("server", "max_players", max_players) password = cfg.get_value("server", "password", password) tick_rate = cfg.get_value("server", "tick_rate", tick_rate) # [game] round_time_seconds = cfg.get_value("game", "round_time_seconds", round_time_seconds) warmup_time_seconds = cfg.get_value("game", "warmup_time_seconds", warmup_time_seconds) friendly_fire = cfg.get_value("game", "friendly_fire", friendly_fire) ff_damage_multiplier = cfg.get_value("game", "ff_damage_multiplier", ff_damage_multiplier) gravity = cfg.get_value("game", "gravity", gravity) respawn_time_seconds = cfg.get_value("game", "respawn_time_seconds", respawn_time_seconds) spectate_enabled = cfg.get_value("game", "spectate_enabled", spectate_enabled) # [movement] movement_walk_speed = cfg.get_value("movement", "walk_speed", movement_walk_speed) movement_sprint_speed = cfg.get_value("movement", "sprint_speed", movement_sprint_speed) movement_crouch_speed = cfg.get_value("movement", "crouch_speed", movement_crouch_speed) movement_jump_velocity = cfg.get_value("movement", "jump_velocity", movement_jump_velocity) movement_acceleration = cfg.get_value("movement", "acceleration", movement_acceleration) movement_air_acceleration = cfg.get_value("movement", "air_acceleration", movement_air_acceleration) movement_friction = cfg.get_value("movement", "friction", movement_friction) # [match] win_limit = cfg.get_value("match", "win_limit", win_limit) time_limit_seconds = cfg.get_value("match", "time_limit_seconds", time_limit_seconds) map_rotation_mode = cfg.get_value("match", "map_rotation_mode", map_rotation_mode) maps = cfg.get_value("match", "maps", maps) # Parse map list map_list.clear() var raw := maps.strip_edges() if not raw.is_empty(): for m in raw.split(","): var trimmed := m.strip_edges() if not trimmed.is_empty(): map_list.append(trimmed) # [rcon] rcon_enabled = cfg.get_value("rcon", "enabled", rcon_enabled) rcon_password = cfg.get_value("rcon", "password", rcon_password) rcon_port = cfg.get_value("rcon", "port", rcon_port) # [logging] log_level = cfg.get_value("logging", "log_level", log_level) log_file = cfg.get_value("logging", "log_file", log_file) # [teams] team_count = cfg.get_value("teams", "team_count", team_count) players_per_team = cfg.get_value("teams", "players_per_team", players_per_team) # Validation port = clampi(port, 1024, 65535) max_players = clampi(max_players, 1, 64) tick_rate = clampi(tick_rate, 30, 1000) team_count = clampi(team_count, 1, 8) players_per_team = clampi(players_per_team, 1, 32) # --------------------------------------------------------------------------- # Internal: Serialization # --------------------------------------------------------------------------- func _build_json_dict() -> Dictionary: return { "server": { "server_name": server_name, "description": description, "bind_ip": bind_ip, "port": port, "max_players": max_players, "password": "", # intentionally omitted from JSON mirror "tick_rate": tick_rate, }, "game": { "round_time_seconds": round_time_seconds, "warmup_time_seconds": warmup_time_seconds, "friendly_fire": friendly_fire, "ff_damage_multiplier": ff_damage_multiplier, "gravity": gravity, "respawn_time_seconds": respawn_time_seconds, "spectate_enabled": spectate_enabled, }, "movement": { "walk_speed": movement_walk_speed, "sprint_speed": movement_sprint_speed, "crouch_speed": movement_crouch_speed, "jump_velocity": movement_jump_velocity, "acceleration": movement_acceleration, "air_acceleration": movement_air_acceleration, "friction": movement_friction, }, "match": { "win_limit": win_limit, "time_limit_seconds": time_limit_seconds, "map_rotation_mode": map_rotation_mode, "maps": maps, }, "rcon": { "enabled": rcon_enabled, "password": "", # intentionally omitted from JSON mirror "port": rcon_port, }, "logging": { "log_level": log_level, "log_file": log_file, }, "teams": { "team_count": team_count, "players_per_team": players_per_team, }, } ## Populate a ConfigFile object from the current property values. ## Used by save_cfg() to serialise all sections back to disk. func _populate_cfg(cfg: ConfigFile) -> void: # [server] cfg.set_value("server", "server_name", server_name) cfg.set_value("server", "description", description) cfg.set_value("server", "bind_ip", bind_ip) cfg.set_value("server", "port", port) cfg.set_value("server", "max_players", max_players) cfg.set_value("server", "password", password) cfg.set_value("server", "tick_rate", tick_rate) # [game] cfg.set_value("game", "round_time_seconds", round_time_seconds) cfg.set_value("game", "warmup_time_seconds", warmup_time_seconds) cfg.set_value("game", "friendly_fire", friendly_fire) cfg.set_value("game", "ff_damage_multiplier", ff_damage_multiplier) cfg.set_value("game", "gravity", gravity) cfg.set_value("game", "respawn_time_seconds", respawn_time_seconds) cfg.set_value("game", "spectate_enabled", spectate_enabled) # [movement] cfg.set_value("movement", "walk_speed", movement_walk_speed) cfg.set_value("movement", "sprint_speed", movement_sprint_speed) cfg.set_value("movement", "crouch_speed", movement_crouch_speed) cfg.set_value("movement", "jump_velocity", movement_jump_velocity) cfg.set_value("movement", "acceleration", movement_acceleration) cfg.set_value("movement", "air_acceleration", movement_air_acceleration) cfg.set_value("movement", "friction", movement_friction) # [match] cfg.set_value("match", "win_limit", win_limit) cfg.set_value("match", "time_limit_seconds", time_limit_seconds) cfg.set_value("match", "map_rotation_mode", map_rotation_mode) cfg.set_value("match", "maps", maps) # [rcon] cfg.set_value("rcon", "enabled", rcon_enabled) cfg.set_value("rcon", "password", rcon_password) cfg.set_value("rcon", "port", rcon_port) # [logging] cfg.set_value("logging", "log_level", log_level) cfg.set_value("logging", "log_file", log_file) # [teams] cfg.set_value("teams", "team_count", team_count) cfg.set_value("teams", "players_per_team", players_per_team) # --------------------------------------------------------------------------- # Getters # --------------------------------------------------------------------------- func get_config_path() -> String: return _config_path_resolved func get_json_path() -> String: return _json_path_resolved