Files
tactical-shooter/scripts/config/server_config.gd
T
shawn e7299b17e9 Phase 7: netfox + godot-jolt stack upgrade
Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
2026-07-02 17:39:22 -04:00

481 lines
16 KiB
GDScript

## 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