feat(server): make server host and port configurable (#127)

* feat(server): make server host and port configurable

Support configuring server binding address via environment variables
and config files. Priority: ENV > local_config.yml > config.yml > default.

- Add host and port options to system section in config.yml
- Read SERVER_HOST and SERVER_PORT from environment variables
- Default to 127.0.0.1:8002 for security

Set host to "0.0.0.0" to allow LAN access.

* test(server): add comprehensive tests for server binding config

Add 28 test cases covering:
- Environment variable priority (SERVER_HOST, SERVER_PORT)
- Config file reading (system.host, system.port)
- Default value fallback
- OmegaConf integration
- Edge cases (IPv6, empty values, invalid ports)

* docs: add mobile/LAN access instructions

- Add mobile access section to README and EN_README
- Configure Vite to listen on 0.0.0.0 for LAN access in dev mode
- Link to Issue #130 for mobile UI compatibility tracking
This commit is contained in:
Zihao Xu
2026-02-04 05:47:17 -08:00
committed by GitHub
parent f15ee94559
commit bd6f7e67d5
6 changed files with 543 additions and 5 deletions

View File

@@ -171,6 +171,48 @@ If you have Docker installed, this is the easiest way:
Frontend: `http://localhost:8123`
Backend API: `http://localhost:8002`
### 📱 Mobile / LAN Access
You can access the game from other devices on the same network (e.g., phone, tablet).
> ⚠️ **Note**: The mobile UI is not optimized yet. See [Issue #130](https://github.com/4thfever/cultivation-world-simulator/issues/130).
**Configuration steps:**
1. Add to `static/local_config.yml`:
```yaml
system:
host: "0.0.0.0" # Allow LAN access
```
2. If using dev mode (`--dev`), also add to `web/vite.config.ts` in the `server` config:
```typescript
server: {
host: '0.0.0.0', // Add this line
proxy: { ... }
}
```
3. After starting the server, access from your phone:
```
http://<your-computer-lan-ip>:5173 # Dev mode
http://<your-computer-lan-ip>:8002 # Production mode
```
4. Find your computer's LAN IP:
```bash
# macOS
ipconfig getifaddr en0
# Linux
hostname -I
# Windows
ipconfig
```
> 💡 Make sure your phone and computer are on the same WiFi, and the firewall allows the corresponding port.
## 📊 Project Status

View File

@@ -175,6 +175,48 @@
前端:`http://localhost:8123`
后端 API`http://localhost:8002`
### 📱 手机/局域网访问
支持从局域网内的其他设备(如手机、平板)访问游戏。
> ⚠️ **注意**:移动端 UI 目前未做适配优化,体验可能不佳。详见 [Issue #130](https://github.com/4thfever/cultivation-world-simulator/issues/130)。
**配置步骤:**
1. 在 `static/local_config.yml` 中添加:
```yaml
system:
host: "0.0.0.0" # 允许局域网访问
```
2. 如果使用开发模式(`--dev`),还需在 `web/vite.config.ts` 的 `server` 配置中添加:
```typescript
server: {
host: '0.0.0.0', // 添加这一行
proxy: { ... }
}
```
3. 启动服务器后,在手机浏览器访问:
```
http://<电脑局域网IP>:5173 # 开发模式
http://<电脑局域网IP>:8002 # 生产模式
```
4. 查看电脑局域网 IP
```bash
# macOS
ipconfig getifaddr en0
# Linux
hostname -I
# Windows
ipconfig
```
> 💡 确保手机和电脑连接同一个 WiFi且防火墙已放行对应端口。
## 📊 项目状态

View File

@@ -660,7 +660,8 @@ async def lifespan(app: FastAPI):
asyncio.create_task(game_loop())
npm_process = None
host = "127.0.0.1"
# 从环境变量或配置文件读取 host。
host = os.environ.get("SERVER_HOST") or getattr(getattr(CONFIG, "system", None), "host", None) or "127.0.0.1"
if IS_DEV_MODE:
print("🚀 启动开发模式 (Dev Mode)...")
@@ -1863,10 +1864,14 @@ else:
def start():
"""启动服务的入口函数"""
# 改为 8002 端口
# 使用 127.0.0.1 更加安全且避免防火墙弹窗
# 注意:直接传递 app 对象而不是字符串,避免 PyInstaller 打包后找不到模块的问题
uvicorn.run(app, host="127.0.0.1", port=8002)
# 从环境变量或配置文件读取服务器配置。
# 优先级:环境变量 > 配置文件 > 默认值。
# 设置 host 为 "0.0.0.0" 可允许局域网访问。
host = os.environ.get("SERVER_HOST") or getattr(getattr(CONFIG, "system", None), "host", None) or "127.0.0.1"
port = int(os.environ.get("SERVER_PORT") or getattr(getattr(CONFIG, "system", None), "port", None) or 8002)
# 注意:直接传递 app 对象而不是字符串,避免 PyInstaller 打包后找不到模块的问题。
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
start()

View File

@@ -60,6 +60,8 @@ frontend:
cloud_freq: low
system:
language: zh-CN
host: "127.0.0.1" # 服务器绑定地址,设为 "0.0.0.0" 允许局域网访问。
port: 8002 # 服务器端口。
play:
base_benefit_probability: 0.05

View File

@@ -0,0 +1,446 @@
"""
Tests for configurable server host and port binding.
These tests verify:
- Environment variable configuration (SERVER_HOST, SERVER_PORT)
- Config file configuration (system.host, system.port)
- Priority: ENV > config file > default values
- Default fallback behavior
"""
import os
import pytest
from unittest.mock import patch, MagicMock
from omegaconf import OmegaConf
class TestServerHostConfiguration:
"""Tests for server host configuration in lifespan and start functions."""
def test_host_from_env_variable(self):
"""Test SERVER_HOST environment variable takes highest priority."""
mock_config = MagicMock()
mock_config.system.host = "192.168.1.100"
with patch.dict(os.environ, {"SERVER_HOST": "0.0.0.0"}), \
patch("src.server.main.CONFIG", mock_config):
# Simulate the logic used in main.py.
host = os.environ.get("SERVER_HOST") or getattr(
getattr(mock_config, "system", None), "host", None
) or "127.0.0.1"
assert host == "0.0.0.0"
def test_host_from_config_when_no_env(self):
"""Test config file host is used when no environment variable."""
mock_system = MagicMock()
mock_system.host = "192.168.1.100"
mock_config = MagicMock()
mock_config.system = mock_system
with patch.dict(os.environ, {}, clear=True):
# Remove SERVER_HOST if it exists.
os.environ.pop("SERVER_HOST", None)
host = os.environ.get("SERVER_HOST") or getattr(
getattr(mock_config, "system", None), "host", None
) or "127.0.0.1"
assert host == "192.168.1.100"
def test_host_default_when_no_config(self):
"""Test default 127.0.0.1 is used when no env or config."""
mock_config = MagicMock()
mock_config.system = None
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("SERVER_HOST", None)
host = os.environ.get("SERVER_HOST") or getattr(
getattr(mock_config, "system", None), "host", None
) or "127.0.0.1"
assert host == "127.0.0.1"
def test_host_default_when_system_has_no_host(self):
"""Test default is used when system section exists but has no host."""
mock_system = MagicMock(spec=[]) # Empty spec means no attributes.
mock_config = MagicMock()
mock_config.system = mock_system
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("SERVER_HOST", None)
host = os.environ.get("SERVER_HOST") or getattr(
getattr(mock_config, "system", None), "host", None
) or "127.0.0.1"
assert host == "127.0.0.1"
def test_env_overrides_config_host(self):
"""Test environment variable overrides config file value."""
mock_system = MagicMock()
mock_system.host = "10.0.0.1"
mock_config = MagicMock()
mock_config.system = mock_system
with patch.dict(os.environ, {"SERVER_HOST": "0.0.0.0"}):
host = os.environ.get("SERVER_HOST") or getattr(
getattr(mock_config, "system", None), "host", None
) or "127.0.0.1"
assert host == "0.0.0.0"
class TestServerPortConfiguration:
"""Tests for server port configuration in start function."""
def test_port_from_env_variable(self):
"""Test SERVER_PORT environment variable takes highest priority."""
mock_config = MagicMock()
mock_config.system.port = 9000
with patch.dict(os.environ, {"SERVER_PORT": "8080"}):
port = int(
os.environ.get("SERVER_PORT") or getattr(
getattr(mock_config, "system", None), "port", None
) or 8002
)
assert port == 8080
def test_port_from_config_when_no_env(self):
"""Test config file port is used when no environment variable."""
mock_system = MagicMock()
mock_system.port = 9000
mock_config = MagicMock()
mock_config.system = mock_system
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("SERVER_PORT", None)
port = int(
os.environ.get("SERVER_PORT") or getattr(
getattr(mock_config, "system", None), "port", None
) or 8002
)
assert port == 9000
def test_port_default_when_no_config(self):
"""Test default 8002 is used when no env or config."""
mock_config = MagicMock()
mock_config.system = None
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("SERVER_PORT", None)
port = int(
os.environ.get("SERVER_PORT") or getattr(
getattr(mock_config, "system", None), "port", None
) or 8002
)
assert port == 8002
def test_port_as_string_converted_to_int(self):
"""Test port from env variable (string) is converted to int."""
with patch.dict(os.environ, {"SERVER_PORT": "3000"}):
port = int(
os.environ.get("SERVER_PORT") or 8002
)
assert port == 3000
assert isinstance(port, int)
class TestStartFunction:
"""Tests for the start() function server binding."""
def test_start_uses_default_host_and_port(self):
"""Test start() uses default values when no config."""
from src.server import main
mock_config = MagicMock()
mock_config.system = None
with patch.dict(os.environ, {}, clear=True), \
patch.object(main, "CONFIG", mock_config), \
patch.object(main, "uvicorn") as mock_uvicorn:
os.environ.pop("SERVER_HOST", None)
os.environ.pop("SERVER_PORT", None)
main.start()
mock_uvicorn.run.assert_called_once()
call_kwargs = mock_uvicorn.run.call_args
assert call_kwargs[1]["host"] == "127.0.0.1"
assert call_kwargs[1]["port"] == 8002
def test_start_uses_env_variables(self):
"""Test start() uses environment variables when set."""
from src.server import main
mock_config = MagicMock()
mock_system = MagicMock()
mock_system.host = "10.0.0.1"
mock_system.port = 9000
mock_config.system = mock_system
with patch.dict(os.environ, {"SERVER_HOST": "0.0.0.0", "SERVER_PORT": "8080"}), \
patch.object(main, "CONFIG", mock_config), \
patch.object(main, "uvicorn") as mock_uvicorn:
main.start()
mock_uvicorn.run.assert_called_once()
call_kwargs = mock_uvicorn.run.call_args
assert call_kwargs[1]["host"] == "0.0.0.0"
assert call_kwargs[1]["port"] == 8080
def test_start_uses_config_values(self):
"""Test start() uses config file values when no env variables."""
from src.server import main
mock_system = MagicMock()
mock_system.host = "192.168.0.1"
mock_system.port = 3000
mock_config = MagicMock()
mock_config.system = mock_system
with patch.dict(os.environ, {}, clear=True), \
patch.object(main, "CONFIG", mock_config), \
patch.object(main, "uvicorn") as mock_uvicorn:
os.environ.pop("SERVER_HOST", None)
os.environ.pop("SERVER_PORT", None)
main.start()
mock_uvicorn.run.assert_called_once()
call_kwargs = mock_uvicorn.run.call_args
assert call_kwargs[1]["host"] == "192.168.0.1"
assert call_kwargs[1]["port"] == 3000
def test_start_env_overrides_config(self):
"""Test environment variables override config file in start()."""
from src.server import main
mock_system = MagicMock()
mock_system.host = "10.0.0.1"
mock_system.port = 9000
mock_config = MagicMock()
mock_config.system = mock_system
# Only set SERVER_HOST, not SERVER_PORT.
with patch.dict(os.environ, {"SERVER_HOST": "0.0.0.0"}, clear=True), \
patch.object(main, "CONFIG", mock_config), \
patch.object(main, "uvicorn") as mock_uvicorn:
os.environ.pop("SERVER_PORT", None)
main.start()
call_kwargs = mock_uvicorn.run.call_args
# HOST from env.
assert call_kwargs[1]["host"] == "0.0.0.0"
# PORT from config.
assert call_kwargs[1]["port"] == 9000
class TestLifespanHostConfiguration:
"""Tests for host configuration in lifespan function."""
def test_lifespan_host_from_env(self):
"""Test lifespan uses SERVER_HOST environment variable."""
from src.server import main
mock_config = MagicMock()
mock_system = MagicMock()
mock_system.host = "10.0.0.1"
mock_config.system = mock_system
with patch.dict(os.environ, {"SERVER_HOST": "0.0.0.0"}), \
patch.object(main, "CONFIG", mock_config):
# Simulate the logic in lifespan.
host = os.environ.get("SERVER_HOST") or getattr(
getattr(main.CONFIG, "system", None), "host", None
) or "127.0.0.1"
assert host == "0.0.0.0"
def test_lifespan_host_from_config(self):
"""Test lifespan uses config host when no env variable."""
from src.server import main
mock_system = MagicMock()
mock_system.host = "192.168.1.50"
mock_config = MagicMock()
mock_config.system = mock_system
with patch.dict(os.environ, {}, clear=True), \
patch.object(main, "CONFIG", mock_config):
os.environ.pop("SERVER_HOST", None)
host = os.environ.get("SERVER_HOST") or getattr(
getattr(main.CONFIG, "system", None), "host", None
) or "127.0.0.1"
assert host == "192.168.1.50"
class TestOmegaConfIntegration:
"""Tests using actual OmegaConf configuration objects."""
def test_omegaconf_host_access(self):
"""Test accessing host from OmegaConf config object."""
config = OmegaConf.create({
"system": {
"language": "zh-CN",
"host": "0.0.0.0",
"port": 8002
}
})
host = getattr(getattr(config, "system", None), "host", None)
assert host == "0.0.0.0"
def test_omegaconf_port_access(self):
"""Test accessing port from OmegaConf config object."""
config = OmegaConf.create({
"system": {
"language": "zh-CN",
"host": "127.0.0.1",
"port": 9000
}
})
port = getattr(getattr(config, "system", None), "port", None)
assert port == 9000
def test_omegaconf_missing_system_section(self):
"""Test graceful handling when system section is missing."""
config = OmegaConf.create({
"game": {"init_npc_num": 10}
})
host = getattr(getattr(config, "system", None), "host", None) or "127.0.0.1"
port = getattr(getattr(config, "system", None), "port", None) or 8002
assert host == "127.0.0.1"
assert port == 8002
def test_omegaconf_missing_host_key(self):
"""Test graceful handling when host key is missing from system."""
config = OmegaConf.create({
"system": {
"language": "zh-CN"
}
})
host = getattr(getattr(config, "system", None), "host", None) or "127.0.0.1"
assert host == "127.0.0.1"
def test_omegaconf_merged_config_priority(self):
"""Test config merge priority (local_config overrides base config)."""
base_config = OmegaConf.create({
"system": {
"language": "zh-CN",
"host": "127.0.0.1",
"port": 8002
}
})
local_config = OmegaConf.create({
"system": {
"host": "0.0.0.0"
}
})
# Simulate the merge behavior (local overrides base).
merged = OmegaConf.merge(base_config, local_config)
assert merged.system.host == "0.0.0.0"
assert merged.system.port == 8002 # From base.
assert merged.system.language == "zh-CN" # From base.
class TestEdgeCases:
"""Tests for edge cases and error handling."""
def test_empty_env_variable_uses_config(self):
"""Test empty string env variable falls through to config."""
mock_system = MagicMock()
mock_system.host = "10.0.0.1"
mock_config = MagicMock()
mock_config.system = mock_system
# Empty string is falsy in Python.
with patch.dict(os.environ, {"SERVER_HOST": ""}):
host = os.environ.get("SERVER_HOST") or getattr(
getattr(mock_config, "system", None), "host", None
) or "127.0.0.1"
assert host == "10.0.0.1"
def test_invalid_port_raises_error(self):
"""Test invalid port string raises ValueError."""
with patch.dict(os.environ, {"SERVER_PORT": "not_a_number"}):
with pytest.raises(ValueError):
int(os.environ.get("SERVER_PORT"))
def test_port_zero_is_valid(self):
"""Test port 0 (random port) is accepted."""
with patch.dict(os.environ, {"SERVER_PORT": "0"}):
port = int(os.environ.get("SERVER_PORT") or 8002)
assert port == 0
def test_high_port_number(self):
"""Test high port numbers are accepted."""
with patch.dict(os.environ, {"SERVER_PORT": "65535"}):
port = int(os.environ.get("SERVER_PORT") or 8002)
assert port == 65535
def test_host_ipv6_address(self):
"""Test IPv6 address is accepted as host."""
with patch.dict(os.environ, {"SERVER_HOST": "::"}):
host = os.environ.get("SERVER_HOST") or "127.0.0.1"
assert host == "::"
def test_host_localhost_string(self):
"""Test 'localhost' string is accepted as host."""
with patch.dict(os.environ, {"SERVER_HOST": "localhost"}):
host = os.environ.get("SERVER_HOST") or "127.0.0.1"
assert host == "localhost"
class TestConfigYamlDefaults:
"""Tests to verify the default values in config.yml."""
def test_config_yml_has_default_host(self):
"""Test config.yml contains default host value."""
import yaml
config_path = "static/config.yml"
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
assert "system" in config
assert "host" in config["system"]
assert config["system"]["host"] == "127.0.0.1"
def test_config_yml_has_default_port(self):
"""Test config.yml contains default port value."""
import yaml
config_path = "static/config.yml"
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
assert "system" in config
assert "port" in config["system"]
assert config["system"]["port"] == 8002

View File

@@ -26,6 +26,7 @@ export default defineConfig(({ mode }) => {
assetsDir: 'web_static', // 避免与游戏原本的 /assets 目录冲突
},
server: {
host: '0.0.0.0', // 允许局域网访问
proxy: {
'/api': {
target: API_TARGET,