Compare commits
4
Commits
Release
...
7995f71b4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7995f71b4b | ||
|
|
34fb54990c | ||
|
|
dab76146cf | ||
|
|
881aa33eef |
@@ -0,0 +1,45 @@
|
||||
# Normalize text files by default; platform-specific EOL below.
|
||||
* text=auto
|
||||
|
||||
# Shell / service / config (LF)
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.service text eol=lf
|
||||
*.conf text eol=lf
|
||||
*.json text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.md text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.cmake text eol=lf
|
||||
*.qrc text eol=lf
|
||||
*.h text eol=lf
|
||||
*.cpp text eol=lf
|
||||
*.c text eol=lf
|
||||
*.hpp text eol=lf
|
||||
CMakeLists.txt text eol=lf
|
||||
|
||||
# Windows scripts (CRLF)
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.zip binary
|
||||
*.tar binary
|
||||
*.gz binary
|
||||
*.lib binary
|
||||
*.pdb binary
|
||||
*.obj binary
|
||||
*.o binary
|
||||
*.a binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Linux Compatibility
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "server/**"
|
||||
- ".gitattributes"
|
||||
- ".gitignore"
|
||||
- ".github/workflows/linux-compatibility.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "server/**"
|
||||
- ".gitattributes"
|
||||
- ".gitignore"
|
||||
- ".github/workflows/linux-compatibility.yml"
|
||||
|
||||
jobs:
|
||||
ubuntu:
|
||||
name: Ubuntu dedicated server
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck
|
||||
|
||||
- name: Verify start.sh LF and executable bit
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
data = Path("start.sh").read_bytes()
|
||||
assert b"\r\n" not in data, "start.sh must use LF line endings"
|
||||
PY
|
||||
test -x start.sh
|
||||
|
||||
- name: Syntax and shellcheck
|
||||
run: |
|
||||
bash -n start.sh
|
||||
shellcheck start.sh
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Create clean venv and install server deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r requirements-server.txt pytest
|
||||
# Ensure GUI-only deps are not required for dedicated server
|
||||
! .venv/bin/python -c "import PySide6" 2>/dev/null
|
||||
|
||||
- name: Compile and test
|
||||
run: |
|
||||
.venv/bin/python -m compileall -q .
|
||||
.venv/bin/python -m pytest -q
|
||||
.venv/bin/python test_npc_protocol.py
|
||||
.venv/bin/python test_combat_protocol.py
|
||||
|
||||
arch:
|
||||
name: Arch Linux container
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
pacman -Syu --noconfirm python python-pip shellcheck
|
||||
|
||||
- name: Verify start.sh LF and executable bit
|
||||
run: |
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
data = Path("start.sh").read_bytes()
|
||||
assert b"\r\n" not in data, "start.sh must use LF line endings"
|
||||
PY
|
||||
test -x start.sh
|
||||
|
||||
- name: Syntax and shellcheck
|
||||
run: |
|
||||
bash -n start.sh
|
||||
shellcheck start.sh
|
||||
|
||||
- name: Create clean venv and install server deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r requirements-server.txt pytest
|
||||
|
||||
- name: Compile and test
|
||||
run: |
|
||||
.venv/bin/python -m compileall -q .
|
||||
.venv/bin/python -m pytest -q
|
||||
.venv/bin/python test_npc_protocol.py
|
||||
.venv/bin/python test_combat_protocol.py
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Open Gitea PR on merge to main
|
||||
|
||||
# When main changes on GitHub (i.e. after a PR is merged here), push those
|
||||
# commits to a branch on Gitea and open a pull request there, so the same
|
||||
# change can be reviewed and landed on the Gitea side. One-way: GitHub -> Gitea.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
open-gitea-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push main to Gitea and open a pull request
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_HOST: git.zambazosmedia.group
|
||||
GITEA_USER: nomad
|
||||
GITEA_REPO: Commonwealth-Online/Commonwealth-Online-Server
|
||||
SYNC_BRANCH: sync/from-github
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::Missing GITEA_TOKEN secret. Add a Gitea access token as a"
|
||||
echo "::error::repository secret named GITEA_TOKEN (Settings -> Secrets and"
|
||||
echo "::error::variables -> Actions -> New repository secret)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config user.name "github-sync"
|
||||
git config user.email "github-sync@users.noreply.github.com"
|
||||
|
||||
# Mirror the current main onto a dedicated Gitea branch. Force is safe:
|
||||
# this branch is owned by the automation and only ever tracks GitHub main.
|
||||
git remote add gitea "https://${GITEA_USER}:${GITEA_TOKEN}@${GITEA_HOST}/${GITEA_REPO}.git"
|
||||
git push -f gitea "HEAD:refs/heads/${SYNC_BRANCH}"
|
||||
|
||||
# Open a PR on Gitea: sync/from-github -> main. If one is already open,
|
||||
# the push above has already updated it, so a 409 is success too.
|
||||
http_code=$(curl -sS -o /tmp/gitea_pr.json -w "%{http_code}" -X POST \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"title\":\"Sync from GitHub main\",\"head\":\"${SYNC_BRANCH}\",\"base\":\"main\",\"body\":\"Automated: GitHub main was updated. Review and merge to land it on Gitea.\"}")
|
||||
|
||||
echo "Gitea pulls API returned HTTP ${http_code}"
|
||||
cat /tmp/gitea_pr.json || true
|
||||
echo
|
||||
|
||||
if [ "${http_code}" = "201" ]; then
|
||||
echo "Opened a new Gitea pull request."
|
||||
elif [ "${http_code}" = "409" ] || grep -qiE "already exist|issue_exist" /tmp/gitea_pr.json; then
|
||||
echo "A Gitea PR from ${SYNC_BRANCH} is already open; it now has the latest commits."
|
||||
else
|
||||
echo "::warning::Unexpected Gitea response (${http_code}). The branch was pushed;"
|
||||
echo "::warning::open the PR manually on Gitea if it did not appear."
|
||||
fi
|
||||
@@ -42,6 +42,7 @@ __pycache__/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.venv-ci/
|
||||
venv/
|
||||
env/
|
||||
|
||||
@@ -49,5 +50,6 @@ env/
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
logs/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
+32
-2
@@ -162,11 +162,12 @@ host-gui/
|
||||
|
||||
## Creating a Portable Distribution
|
||||
|
||||
### Windows Host GUI (unchanged)
|
||||
|
||||
To create a self-contained package anyone can run:
|
||||
|
||||
```bash
|
||||
# Build the application
|
||||
cd host-gui
|
||||
# Build the application from the repository root
|
||||
build.bat
|
||||
|
||||
# Deploy DLLs
|
||||
@@ -183,6 +184,35 @@ copy README.md Commonwealth-Online-Host\README.txt
|
||||
```
|
||||
|
||||
Users extract and run `CommonwealthOnlineHost.exe` - no setup needed!
|
||||
Target machines still need Python 3.9+ on PATH for the bundled relay server.
|
||||
|
||||
### Linux dedicated server packaging
|
||||
|
||||
Prefer a `.tar.gz` archive of the `server/` directory so the executable bit on `start.sh` is retained.
|
||||
|
||||
Do **not** include:
|
||||
|
||||
- `server/.venv/`
|
||||
- `server/__pycache__/`
|
||||
- `server/logs/`
|
||||
- local runtime files such as operator-specific `bans.json` unless intentional
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
tar --exclude='.venv' --exclude='__pycache__' --exclude='logs' \
|
||||
--exclude='*.pyc' -czf commonwealth-online-server-linux.tar.gz -C server .
|
||||
```
|
||||
|
||||
If you also ship a ZIP archive, document that some extraction tools may drop Unix executable permissions. Recipients can recover with:
|
||||
|
||||
```bash
|
||||
sed -i 's/\r$//' start.sh
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Windows Host GUI packaging remains ZIP-based and is unchanged by the Linux packaging guidance above.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,6 +6,28 @@ Production-ready Qt6 GUI application for hosting Commonwealth Online servers on
|
||||
|
||||
This repository is for building the **Host GUI**. The GUI wraps the same Python server from `server/` in a native Windows UI.
|
||||
|
||||
## Linux dedicated server
|
||||
|
||||
The dedicated server lives in [`server/`](server/) and is the supported way to host on Ubuntu, Debian, Arch Linux, CachyOS, Fedora, and similar distributions.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
# Ubuntu/Debian: sudo apt install python3 python3-venv
|
||||
# Arch/CachyOS: sudo pacman -S --needed python
|
||||
# Fedora: sudo dnf install python3
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Important notes:
|
||||
|
||||
- `./start.sh` creates a local `.venv` and never uses `--break-system-packages`
|
||||
- Do not run `sudo ./start.sh`
|
||||
- Fish shell users do not need to activate the virtual environment
|
||||
- Allow TCP `7777` (and optionally UDP `7778` for LAN discovery) through the firewall
|
||||
- `0.0.0.0` is a bind address, not the address players should join
|
||||
- See [server/README.md](server/README.md) for systemd, journalctl, CRLF recovery, and admin-CLI usage
|
||||
|
||||
## Quick Start (GUI)
|
||||
|
||||
**First time? Follow the [Setup Instructions](SETUP.md)**
|
||||
|
||||
@@ -19,7 +19,11 @@ file(MAKE_DIRECTORY "${CO_SERVER_STAGE_DIR}")
|
||||
# Prefer a filtered copy so Python caches do not ship with the build.
|
||||
file(GLOB _server_entries RELATIVE "${CO_SERVER_SOURCE_DIR}" "${CO_SERVER_SOURCE_DIR}/*")
|
||||
foreach(_entry IN LISTS _server_entries)
|
||||
if(_entry STREQUAL "__pycache__" OR _entry STREQUAL ".pytest_cache")
|
||||
if(_entry STREQUAL "__pycache__"
|
||||
OR _entry STREQUAL ".pytest_cache"
|
||||
OR _entry STREQUAL ".venv"
|
||||
OR _entry STREQUAL "logs"
|
||||
OR _entry STREQUAL "tests")
|
||||
continue()
|
||||
endif()
|
||||
file(COPY "${CO_SERVER_SOURCE_DIR}/${_entry}"
|
||||
@@ -27,6 +31,8 @@ foreach(_entry IN LISTS _server_entries)
|
||||
PATTERN "__pycache__" EXCLUDE
|
||||
PATTERN "*.pyc" EXCLUDE
|
||||
PATTERN ".pytest_cache" EXCLUDE
|
||||
PATTERN ".venv" EXCLUDE
|
||||
PATTERN "logs" EXCLUDE
|
||||
)
|
||||
endforeach()
|
||||
|
||||
|
||||
@@ -31,7 +31,13 @@ Option 3: Use the CLI with port override
|
||||
-----------------------------------------
|
||||
Open a terminal in the server folder and run:
|
||||
|
||||
python consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
# Windows
|
||||
.venv\Scripts\python.exe -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
|
||||
# Linux / macOS
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
|
||||
If .venv does not exist yet, run start.bat / ./start.sh once first.
|
||||
|
||||
Option 4: Find and kill the blocking process manually
|
||||
------------------------------------------------------
|
||||
|
||||
+157
-47
@@ -1,26 +1,32 @@
|
||||
# Commonwealth Online Server - Quick Start
|
||||
|
||||
Standalone dedicated relay server. No Qt/Host GUI build is required.
|
||||
|
||||
## Quick Launch
|
||||
|
||||
**Windows:** double-click `start.bat`, or run it from a terminal:
|
||||
### Windows
|
||||
|
||||
```
|
||||
Double-click `start.bat`, or run it from a terminal:
|
||||
|
||||
```bat
|
||||
start.bat
|
||||
```
|
||||
|
||||
**Linux / macOS:**
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
chmod +x start.sh fix-port.sh # once
|
||||
chmod +x start.sh fix-port.sh # recovery step if the executable bit was lost
|
||||
./start.sh
|
||||
```
|
||||
|
||||
The start script will:
|
||||
|
||||
1. Check that Python 3.9+ is installed
|
||||
2. Install required dependencies (typer, rich)
|
||||
3. Generate a default `commonwealth-server.json` config file (if needed)
|
||||
4. Start the server listening on `0.0.0.0:7777`
|
||||
5. Open an interactive `commonwealth>` prompt in the same window
|
||||
2. Create a local `.venv` virtual environment (never installs into the OS Python)
|
||||
3. Install dedicated-server dependencies from `requirements-server.txt` when needed
|
||||
4. Generate a default `commonwealth-server.json` config file (if needed)
|
||||
5. Start `consumer_server_cli.py` listening on `0.0.0.0:7777` by default
|
||||
6. Open an interactive `commonwealth>` prompt when stdin/stdout are a real terminal
|
||||
|
||||
At the prompt you can type commands directly, for example:
|
||||
|
||||
@@ -35,6 +41,109 @@ quit
|
||||
|
||||
`users` live-updates the player table every second (press Enter to stop).
|
||||
|
||||
Update dependencies explicitly when needed:
|
||||
|
||||
```bash
|
||||
./start.sh --update-dependencies
|
||||
```
|
||||
|
||||
`--update-dependencies` is consumed by the start script and is **not** forwarded to the server.
|
||||
|
||||
If a file manager “Open with” passes `commonwealth-server.json` as an argument, `start.sh` treats that path as the config file and still launches with `--config` (it is not forwarded as a bare positional argument).
|
||||
|
||||
---
|
||||
|
||||
## Linux Installation
|
||||
|
||||
Do **not** run `sudo ./start.sh`. Do **not** use `pip install --break-system-packages`.
|
||||
The script creates `.venv` automatically and invokes `.venv/bin/python` directly.
|
||||
Fish users do not need to activate anything.
|
||||
|
||||
### Ubuntu and Debian
|
||||
|
||||
```bash
|
||||
sudo apt install python3 python3-venv
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Arch Linux and CachyOS
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed python
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Fedora
|
||||
|
||||
```bash
|
||||
sudo dnf install python3
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Firewall and networking
|
||||
|
||||
- Allow **TCP 7777** (or your configured game port) through the host firewall.
|
||||
- LAN discovery uses **UDP 7778**. If discovery is blocked, clients can still connect directly by IP/port.
|
||||
- The admin channel binds to **127.0.0.1:7779** and should stay localhost-only.
|
||||
- Router port forwarding is only needed when hosting behind a home router for outside connections.
|
||||
- VPS users normally only need the provider firewall and OS firewall configured.
|
||||
- `0.0.0.0` is a **bind address**, not the address clients should enter.
|
||||
- Direct connections still work if LAN discovery is unavailable.
|
||||
|
||||
Example firewall openings:
|
||||
|
||||
```bash
|
||||
# firewalld
|
||||
sudo firewall-cmd --add-port=7777/tcp --permanent
|
||||
sudo firewall-cmd --add-port=7778/udp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
|
||||
# ufw
|
||||
sudo ufw allow 7777/tcp
|
||||
sudo ufw allow 7778/udp
|
||||
```
|
||||
|
||||
### systemd (optional)
|
||||
|
||||
1. Install the server files under `/opt/commonwealth-online` (or another path).
|
||||
2. Create an unprivileged `commonwealth` user/group.
|
||||
3. Run `./start.sh --update-dependencies` once as that user to create `.venv`.
|
||||
4. Copy and edit [`commonwealth-online.service.example`](commonwealth-online.service.example):
|
||||
|
||||
```bash
|
||||
sudo cp commonwealth-online.service.example /etc/systemd/system/commonwealth-online.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now commonwealth-online
|
||||
journalctl -u commonwealth-online -f
|
||||
```
|
||||
|
||||
Manage a headless/systemd server from another shell with the admin CLI:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -u consumer_server_cli.py status
|
||||
.venv/bin/python -u consumer_server_cli.py users
|
||||
.venv/bin/python -u consumer_server_cli.py help
|
||||
```
|
||||
|
||||
`systemctl stop commonwealth-online` sends SIGTERM and the server shuts down cleanly.
|
||||
|
||||
### CRLF / executable-bit recovery
|
||||
|
||||
Git clones should keep LF endings for `start.sh` because of `.gitattributes`.
|
||||
If you extracted a ZIP on Windows or otherwise lost Unix permissions/line endings:
|
||||
|
||||
```bash
|
||||
sed -i 's/\r$//' start.sh
|
||||
chmod +x start.sh
|
||||
```
|
||||
|
||||
Prefer `.tar.gz` Linux releases so the executable bit is retained.
|
||||
|
||||
---
|
||||
|
||||
### First Run
|
||||
|
||||
On first run, a default `commonwealth-server.json` file is created in the server directory with these settings:
|
||||
@@ -44,91 +153,92 @@ On first run, a default `commonwealth-server.json` file is created in the server
|
||||
"host": "0.0.0.0",
|
||||
"port": 7777,
|
||||
"server_name": "Commonwealth Online Server",
|
||||
"server_description": "",
|
||||
"max_players": 16,
|
||||
"log_verbosity": "info"
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 7779
|
||||
}
|
||||
```
|
||||
|
||||
### Port Already in Use?
|
||||
|
||||
If you get an error like "Only one usage of each socket address ... is normally permitted", port 7777 is already in use by another process.
|
||||
If you get an address-already-in-use error, the game port is already taken.
|
||||
|
||||
**Option 1: Kill the blocking process**
|
||||
|
||||
Run `fix-port.bat` (Windows) or `./fix-port.sh` (Linux / macOS) to:
|
||||
- Detect which process is using port 7777
|
||||
- Kill the process
|
||||
- Restart the server
|
||||
Run `fix-port.bat` (Windows) or `./fix-port.sh` (Linux / macOS).
|
||||
|
||||
**Option 2: Use a different port**
|
||||
|
||||
Run `fix-port.bat` / `./fix-port.sh` and select "Use a different port" to change the port in your config file.
|
||||
|
||||
Alternatively, manually edit `commonwealth-server.json` and change the `"port"` value to something like `7778`, `8000`, or `9999`.
|
||||
Use the same helper and choose “Use a different port”, or edit `commonwealth-server.json`.
|
||||
|
||||
### Customizing the Server
|
||||
|
||||
Edit `commonwealth-server.json` to customize:
|
||||
|
||||
- **host**: Bind address (default `0.0.0.0` for all interfaces)
|
||||
- **port**: Server port (default `7777`)
|
||||
- **port**: Game TCP port (default `7777`)
|
||||
- **server_name**: Display name
|
||||
- **server_description**: Optional short description
|
||||
- **max_players**: Metadata for client displays
|
||||
- **log_verbosity**: `debug`, `info`, `warning`, or `error` (`info` hides per-packet transform spam; use `debug` for protocol troubleshooting)
|
||||
- **log_verbosity**: `debug`, `info`, `warning`, or `error`
|
||||
- **admin_port**: Localhost admin TCP port (default `7779`)
|
||||
|
||||
### Connecting Clients
|
||||
|
||||
Once the server is running:
|
||||
|
||||
- **Local PC**: Connect to `127.0.0.1:7777` (or your custom port)
|
||||
- **LAN**: Connect to your PC's local IP (displayed on startup, e.g., `192.168.1.64:7777`)
|
||||
- **Remote**: Forward port 7777/TCP on your router and use your public IP
|
||||
- **LAN**: Connect to your PC's local IP (displayed on startup, e.g. `192.168.1.64:7777`)
|
||||
- **Remote**: Forward TCP 7777 on your router and use your public IP
|
||||
|
||||
### Stopping the Server
|
||||
|
||||
Type `quit` at the `commonwealth>` prompt, or press `Ctrl+C`.
|
||||
Under systemd use `systemctl stop commonwealth-online`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `requirements-server.txt` | Dedicated CLI/headless server (typer, rich) |
|
||||
| `requirements-host-gui.txt` | Optional Python PySide6 GUI (`dev_server_app.py`) |
|
||||
| `requirements.txt` | Compatibility aggregate (includes PySide6) |
|
||||
|
||||
Dedicated servers should install only `requirements-server.txt`. The Windows Qt Host GUI wraps this CLI and does not need PySide6.
|
||||
|
||||
---
|
||||
|
||||
## For Advanced Users (Command Line)
|
||||
|
||||
If you prefer command-line usage, use the CLI directly:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements-server.txt
|
||||
|
||||
# Generate config
|
||||
python consumer_server_cli.py config init my-config.json
|
||||
.venv/bin/python -u consumer_server_cli.py config init my-config.json
|
||||
|
||||
# Start server with interactive prompt (same as start.bat / start.sh)
|
||||
python consumer_server_cli.py serve --config my-config.json --interactive
|
||||
# Start server with interactive prompt (same as start.sh on a TTY)
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config my-config.json --interactive
|
||||
|
||||
# Start server without a prompt (Host GUI / headless)
|
||||
python consumer_server_cli.py serve --config my-config.json
|
||||
# Start server without a prompt (Host GUI / systemd / headless)
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config my-config.json
|
||||
|
||||
# Management commands from another terminal while a server is running
|
||||
python consumer_server_cli.py help
|
||||
python consumer_server_cli.py status
|
||||
python consumer_server_cli.py users
|
||||
python consumer_server_cli.py world time 1430
|
||||
python consumer_server_cli.py world weather 0002b52a
|
||||
.venv/bin/python -u consumer_server_cli.py help
|
||||
.venv/bin/python -u consumer_server_cli.py status
|
||||
.venv/bin/python -u consumer_server_cli.py users
|
||||
.venv/bin/python -u consumer_server_cli.py world time 1430
|
||||
.venv/bin/python -u consumer_server_cli.py world weather 0002b52a
|
||||
```
|
||||
|
||||
Run `python consumer_server_cli.py --help` to see all available commands.
|
||||
Run `.venv/bin/python -u consumer_server_cli.py --help` to see all available commands.
|
||||
|
||||
### Troubleshooting: Port in Use
|
||||
|
||||
If using the CLI, manually change the port:
|
||||
|
||||
```bash
|
||||
# Generate config with custom port
|
||||
python consumer_server_cli.py config init my-config.json
|
||||
# Edit my-config.json and change "port" to a different number
|
||||
python consumer_server_cli.py serve --config my-config.json --port 8000
|
||||
```
|
||||
|
||||
Or use the CLI port override:
|
||||
|
||||
```bash
|
||||
python consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
|
||||
```
|
||||
|
||||
+36
-18
@@ -34,6 +34,7 @@ class AdminServer:
|
||||
self._lock = threading.RLock()
|
||||
self._server_socket: socket.socket | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._client_sockets: set[socket.socket] = set()
|
||||
self._running = False
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -50,9 +51,12 @@ class AdminServer:
|
||||
server_socket.bind((ADMIN_HOST, self.port))
|
||||
server_socket.listen()
|
||||
server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS)
|
||||
except OSError:
|
||||
except OSError as error:
|
||||
server_socket.close()
|
||||
raise
|
||||
raise OSError(
|
||||
f"Could not bind the admin server to {ADMIN_HOST}:{self.port}. "
|
||||
f"The port may already be in use or unavailable. ({error})"
|
||||
) from error
|
||||
|
||||
self._server_socket = server_socket
|
||||
self._running = True
|
||||
@@ -65,6 +69,10 @@ class AdminServer:
|
||||
self._running = False
|
||||
server_socket = self._server_socket
|
||||
self._server_socket = None
|
||||
clients = list(self._client_sockets)
|
||||
self._client_sockets.clear()
|
||||
thread = self._thread
|
||||
self._thread = None
|
||||
|
||||
if server_socket is not None:
|
||||
try:
|
||||
@@ -72,10 +80,14 @@ class AdminServer:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
thread = self._thread
|
||||
for client in clients:
|
||||
try:
|
||||
client.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=1.0)
|
||||
self._thread = None
|
||||
|
||||
def is_running(self) -> bool:
|
||||
with self._lock:
|
||||
@@ -99,6 +111,9 @@ class AdminServer:
|
||||
except OSError:
|
||||
break
|
||||
|
||||
with self._lock:
|
||||
self._client_sockets.add(connection)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._handle_connection,
|
||||
args=(connection, address),
|
||||
@@ -111,24 +126,27 @@ class AdminServer:
|
||||
|
||||
def _handle_connection(self, connection: socket.socket, address: tuple[str, int]) -> None:
|
||||
peer = f"{address[0]}:{address[1]}"
|
||||
with connection:
|
||||
buffer = ""
|
||||
try:
|
||||
try:
|
||||
with connection:
|
||||
buffer = b""
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
line = line.strip()
|
||||
buffer += chunk
|
||||
while b"\n" in buffer:
|
||||
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||
line = line_bytes.decode("utf-8", errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
response = self._dispatch_line(line)
|
||||
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
connection.sendall(encoded)
|
||||
except OSError as error:
|
||||
self._log(f"Admin connection error from {peer}: {error}")
|
||||
except OSError as error:
|
||||
self._log(f"Admin connection error from {peer}: {error}")
|
||||
finally:
|
||||
with self._lock:
|
||||
self._client_sockets.discard(connection)
|
||||
|
||||
def _dispatch_line(self, line: str) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -160,14 +178,14 @@ def send_admin_command(
|
||||
encoded = json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
with socket.create_connection((host, port), timeout=timeout_seconds) as connection:
|
||||
connection.sendall(encoded)
|
||||
buffer = ""
|
||||
while "\n" not in buffer:
|
||||
buffer = b""
|
||||
while b"\n" not in buffer:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("Admin server closed the connection without a response.")
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
line, _rest = buffer.split("\n", 1)
|
||||
response = json.loads(line)
|
||||
buffer += chunk
|
||||
line_bytes, _rest = buffer.split(b"\n", 1)
|
||||
response = json.loads(line_bytes.decode("utf-8"))
|
||||
if not isinstance(response, dict):
|
||||
raise ValueError("Admin response must be a JSON object.")
|
||||
return response
|
||||
|
||||
+2
-2
@@ -91,8 +91,8 @@ class BanStore:
|
||||
payload = {
|
||||
"banned_ips": [entry.to_dict() for entry in sorted(self._bans.values(), key=lambda e: e.ip)]
|
||||
}
|
||||
with open(self._path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2)
|
||||
with open(self._path, "w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
|
||||
def is_banned(self, ip: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Commonwealth Online Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=commonwealth
|
||||
Group=commonwealth
|
||||
WorkingDirectory=/opt/commonwealth-online
|
||||
# Complete setup before enabling this unit:
|
||||
# cd /opt/commonwealth-online
|
||||
# ./start.sh --update-dependencies
|
||||
# Do not install dependencies from ExecStart.
|
||||
ExecStart=/opt/commonwealth-online/.venv/bin/python -u /opt/commonwealth-online/consumer_server_cli.py serve --config /opt/commonwealth-online/commonwealth-server.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# Do not run as root. Create the commonwealth user/group first.
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+62
-13
@@ -7,7 +7,7 @@ Supports JSON-based configuration files for hosted deployment.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,7 +15,7 @@ from typing import Any
|
||||
|
||||
SERVER_NAME_MAX_LENGTH = 64
|
||||
SERVER_DESCRIPTION_MAX_LENGTH = 256
|
||||
|
||||
MAX_PLAYERS_HARD_LIMIT = 256
|
||||
|
||||
DEFAULT_ADMIN_PORT = 7779
|
||||
|
||||
@@ -46,14 +46,21 @@ class Config:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Config:
|
||||
"""Create config from dictionary."""
|
||||
try:
|
||||
port = int(data.get("port", 7777))
|
||||
max_players = int(data.get("max_players", 16))
|
||||
admin_port = int(data.get("admin_port", DEFAULT_ADMIN_PORT))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"Invalid numeric config field: {error}") from error
|
||||
|
||||
return cls(
|
||||
host=data.get("host", "0.0.0.0"),
|
||||
port=int(data.get("port", 7777)),
|
||||
server_name=data.get("server_name", "Commonwealth Online Server"),
|
||||
host=str(data.get("host", "0.0.0.0")),
|
||||
port=port,
|
||||
server_name=str(data.get("server_name", "Commonwealth Online Server")),
|
||||
server_description=str(data.get("server_description", "")),
|
||||
max_players=int(data.get("max_players", 16)),
|
||||
log_verbosity=data.get("log_verbosity", "info"),
|
||||
admin_port=int(data.get("admin_port", DEFAULT_ADMIN_PORT)),
|
||||
max_players=max_players,
|
||||
log_verbosity=str(data.get("log_verbosity", "info")),
|
||||
admin_port=admin_port,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,6 +77,7 @@ def load_config(config_path: str | None = None) -> Config:
|
||||
Raises:
|
||||
FileNotFoundError: If config_path is provided but file does not exist.
|
||||
json.JSONDecodeError: If config file is invalid JSON.
|
||||
ValueError: If config root is not an object or values are invalid.
|
||||
"""
|
||||
if config_path is None:
|
||||
return Config()
|
||||
@@ -78,7 +86,7 @@ def load_config(config_path: str | None = None) -> Config:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
with open(path, "r") as f:
|
||||
with open(path, "r", encoding="utf-8", newline=None) as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
@@ -98,8 +106,9 @@ def save_config(config: Config, config_path: str) -> None:
|
||||
path = Path(config_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(config.to_dict(), f, indent=2)
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(config.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def generate_default_config(config_path: str) -> Config:
|
||||
@@ -117,6 +126,25 @@ def generate_default_config(config_path: str) -> Config:
|
||||
return config
|
||||
|
||||
|
||||
def _is_valid_bind_host(host: str) -> bool:
|
||||
"""Return True when host can be used as an AF_INET bind address."""
|
||||
text = host.strip()
|
||||
if not text:
|
||||
return False
|
||||
if text in ("0.0.0.0", "127.0.0.1", "localhost"):
|
||||
return True
|
||||
try:
|
||||
socket.inet_aton(text)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
socket.getaddrinfo(text, None, family=socket.AF_INET, type=socket.SOCK_STREAM)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def validate_config(config: Config) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Validate configuration values.
|
||||
@@ -127,10 +155,14 @@ def validate_config(config: Config) -> tuple[bool, list[str]]:
|
||||
Returns:
|
||||
(is_valid, list_of_errors). Empty list if valid.
|
||||
"""
|
||||
errors = []
|
||||
errors: list[str] = []
|
||||
|
||||
if not config.host:
|
||||
if not config.host or not str(config.host).strip():
|
||||
errors.append("host cannot be empty")
|
||||
elif not _is_valid_bind_host(str(config.host)):
|
||||
errors.append(
|
||||
f"host '{config.host}' is not a valid IPv4 address or resolvable hostname"
|
||||
)
|
||||
|
||||
if config.port < 1 or config.port > 65535:
|
||||
errors.append(f"port must be 1-65535, got {config.port}")
|
||||
@@ -156,8 +188,25 @@ def validate_config(config: Config) -> tuple[bool, list[str]]:
|
||||
|
||||
if config.max_players < 1:
|
||||
errors.append(f"max_players must be >= 1, got {config.max_players}")
|
||||
elif config.max_players > MAX_PLAYERS_HARD_LIMIT:
|
||||
errors.append(
|
||||
f"max_players must be <= {MAX_PLAYERS_HARD_LIMIT}, got {config.max_players}"
|
||||
)
|
||||
|
||||
if config.log_verbosity not in ("debug", "info", "warning", "error"):
|
||||
errors.append(f"log_verbosity must be debug/info/warning/error, got {config.log_verbosity}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def ensure_writable_directory(path: Path) -> None:
|
||||
"""Raise PermissionError when the directory cannot be created or written."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
probe = path / ".commonwealth-write-probe"
|
||||
try:
|
||||
probe.write_text("ok\n", encoding="utf-8", newline="\n")
|
||||
finally:
|
||||
try:
|
||||
probe.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
+144
-13
@@ -23,7 +23,11 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -40,10 +44,19 @@ from rich import box
|
||||
|
||||
from admin_server import send_admin_command
|
||||
from server_service import ServerService, ServerConfig, looks_like_ipv4
|
||||
from config import Config, load_config, generate_default_config, validate_config, DEFAULT_ADMIN_PORT as CONFIG_DEFAULT_ADMIN_PORT
|
||||
from config import (
|
||||
Config,
|
||||
load_config,
|
||||
generate_default_config,
|
||||
validate_config,
|
||||
ensure_writable_directory,
|
||||
DEFAULT_ADMIN_PORT as CONFIG_DEFAULT_ADMIN_PORT,
|
||||
)
|
||||
|
||||
# Rich console for beautiful output
|
||||
console = Console()
|
||||
# Rich console for beautiful output when attached to a TTY.
|
||||
_FORCE_COLOR = os.environ.get("FORCE_COLOR", "").strip() not in ("", "0", "false", "False")
|
||||
_USE_COLOR = _FORCE_COLOR or (sys.stdout.isatty() and os.environ.get("NO_COLOR") is None)
|
||||
console = Console(force_terminal=_USE_COLOR, color_system="auto" if _USE_COLOR else None)
|
||||
app = typer.Typer(
|
||||
name="commonwealth",
|
||||
help="Commonwealth Online Server CLI",
|
||||
@@ -52,6 +65,8 @@ app = typer.Typer(
|
||||
|
||||
# Global service instance (used by the serve process only)
|
||||
_service: Optional[ServerService] = None
|
||||
_shutdown_requested = threading.Event()
|
||||
_logger = logging.getLogger("commonwealth.server")
|
||||
|
||||
|
||||
def get_service() -> ServerService:
|
||||
@@ -62,10 +77,61 @@ def get_service() -> ServerService:
|
||||
return _service
|
||||
|
||||
|
||||
def log_callback(message: str) -> None:
|
||||
def _configure_logging() -> None:
|
||||
if _logger.handlers:
|
||||
return
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
fmt="%(asctime)s %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
handler.flush = sys.stdout.flush # type: ignore[method-assign]
|
||||
_logger.setLevel(logging.INFO)
|
||||
_logger.addHandler(handler)
|
||||
_logger.propagate = False
|
||||
|
||||
|
||||
def log_callback(message: str, *, level: str = "info") -> None:
|
||||
"""Callback for server logs from the service."""
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
console.print(f"[dim]{timestamp}[/dim] {message}")
|
||||
severity = str(level or "info").strip().lower()
|
||||
if _USE_COLOR:
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
console.print(f"[dim]{timestamp}[/dim] [{severity}] {message}")
|
||||
return
|
||||
|
||||
_configure_logging()
|
||||
log_level = {
|
||||
"debug": logging.DEBUG,
|
||||
"info": logging.INFO,
|
||||
"warning": logging.WARNING,
|
||||
"error": logging.ERROR,
|
||||
}.get(severity, logging.INFO)
|
||||
_logger.log(log_level, message)
|
||||
for handler in _logger.handlers:
|
||||
handler.flush()
|
||||
|
||||
|
||||
def _require_python_version() -> None:
|
||||
if sys.version_info < (3, 9):
|
||||
console.print(
|
||||
f"[red]ERROR: Python 3.9+ is required (found {sys.version.split()[0]}).[/red]"
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def _request_shutdown(_signum: int, _frame: Any) -> None:
|
||||
"""Handle SIGINT/SIGTERM by requesting a clean shutdown."""
|
||||
_shutdown_requested.set()
|
||||
service = _service
|
||||
if service is not None:
|
||||
try:
|
||||
service.stop()
|
||||
except Exception:
|
||||
pass
|
||||
# Interrupt blocking main-thread waits (accept/input) so shutdown completes.
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
def admin_request(
|
||||
@@ -206,6 +272,10 @@ def _print_help_table(*, interactive: bool = False) -> None:
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
config_path: Optional[str] = typer.Argument(
|
||||
None,
|
||||
help="Optional path to config.json (same as --config)",
|
||||
),
|
||||
config: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--config",
|
||||
@@ -232,8 +302,25 @@ def serve(
|
||||
),
|
||||
) -> None:
|
||||
"""Start the Commonwealth Online relay server."""
|
||||
_require_python_version()
|
||||
service: Optional[ServerService] = None
|
||||
_shutdown_requested.clear()
|
||||
|
||||
# Accept either `serve --config FILE` or `serve FILE` (file managers may pass FILE).
|
||||
if config and config_path and Path(config).resolve() != Path(config_path).resolve():
|
||||
console.print(
|
||||
"[red]Error: Conflicting config paths from positional argument and --config.[/red]"
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
config = config or config_path
|
||||
|
||||
previous_sigint = signal.getsignal(signal.SIGINT)
|
||||
previous_sigterm = signal.getsignal(signal.SIGTERM) if hasattr(signal, "SIGTERM") else None
|
||||
try:
|
||||
signal.signal(signal.SIGINT, _request_shutdown)
|
||||
if hasattr(signal, "SIGTERM"):
|
||||
signal.signal(signal.SIGTERM, _request_shutdown)
|
||||
|
||||
# Load or create config
|
||||
if config:
|
||||
try:
|
||||
@@ -244,10 +331,13 @@ def serve(
|
||||
except json.JSONDecodeError as e:
|
||||
console.print(f"[red]Error: Invalid JSON in config file: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
bans_path = str(Path(config).resolve().parent / "bans.json")
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: Invalid config values: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
bans_path = Path(config).resolve().parent / "bans.json"
|
||||
else:
|
||||
cfg = Config()
|
||||
bans_path = str(Path(__file__).resolve().parent / "bans.json")
|
||||
bans_path = Path(__file__).resolve().parent / "bans.json"
|
||||
|
||||
# Apply CLI overrides
|
||||
if host:
|
||||
@@ -263,6 +353,14 @@ def serve(
|
||||
console.print(f" • {error}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
try:
|
||||
ensure_writable_directory(bans_path.parent)
|
||||
except OSError as error:
|
||||
console.print(
|
||||
f"[red]ERROR: Cannot write server state in {bans_path.parent}: {error}[/red]"
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Create and configure service
|
||||
server_config = ServerConfig(
|
||||
host=cfg.host,
|
||||
@@ -272,7 +370,7 @@ def serve(
|
||||
max_players=cfg.max_players,
|
||||
log_verbosity=cfg.log_verbosity,
|
||||
admin_port=cfg.admin_port,
|
||||
bans_path=bans_path,
|
||||
bans_path=str(bans_path),
|
||||
)
|
||||
|
||||
service = get_service()
|
||||
@@ -282,8 +380,15 @@ def serve(
|
||||
# Print startup banner
|
||||
print_startup_banner(server_config)
|
||||
|
||||
use_interactive = bool(interactive and sys.stdin.isatty() and sys.stdout.isatty())
|
||||
if interactive and not use_interactive:
|
||||
console.print(
|
||||
"[yellow]stdin/stdout are not a terminal; starting non-interactive mode. "
|
||||
"Use the admin CLI against 127.0.0.1 to manage the server.[/yellow]"
|
||||
)
|
||||
|
||||
console.print("[yellow]Starting server...[/yellow]")
|
||||
if interactive:
|
||||
if use_interactive:
|
||||
service.start()
|
||||
if not _wait_for_server_ready(service, timeout_seconds=5.0):
|
||||
console.print("[red]Server failed to become ready.[/red]")
|
||||
@@ -297,8 +402,10 @@ def serve(
|
||||
service.stop()
|
||||
console.print("[green]Server stopped gracefully.[/green]")
|
||||
else:
|
||||
# Non-interactive mode for Host GUI / headless hosting.
|
||||
# Non-interactive mode for Host GUI / systemd / headless hosting.
|
||||
service.serve_forever()
|
||||
if _shutdown_requested.is_set():
|
||||
console.print("[green]Server stopped gracefully.[/green]")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Shutdown signal received. Stopping server...[/yellow]")
|
||||
@@ -306,6 +413,14 @@ def serve(
|
||||
service = get_service()
|
||||
service.stop()
|
||||
console.print("[green]Server stopped gracefully.[/green]")
|
||||
except OSError as e:
|
||||
console.print(f"[red]ERROR: {e}[/red]")
|
||||
if service is not None:
|
||||
try:
|
||||
service.stop()
|
||||
except Exception:
|
||||
pass
|
||||
raise typer.Exit(code=1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Fatal error: {e}[/red]")
|
||||
if service is not None:
|
||||
@@ -314,6 +429,13 @@ def serve(
|
||||
except Exception:
|
||||
pass
|
||||
raise typer.Exit(code=1)
|
||||
finally:
|
||||
try:
|
||||
signal.signal(signal.SIGINT, previous_sigint)
|
||||
if previous_sigterm is not None and hasattr(signal, "SIGTERM"):
|
||||
signal.signal(signal.SIGTERM, previous_sigterm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -987,7 +1109,16 @@ def _dispatch_interactive_command(args: list[str], *, admin_port: int) -> bool:
|
||||
|
||||
def _run_interactive_shell(*, admin_port: int) -> None:
|
||||
"""Read management commands from stdin until quit/exit/Ctrl+C."""
|
||||
while True:
|
||||
if not sys.stdin.isatty():
|
||||
console.print(
|
||||
"[yellow]Interactive prompt requires a terminal. "
|
||||
"Server remains available via the admin port.[/yellow]"
|
||||
)
|
||||
while not _shutdown_requested.is_set():
|
||||
time.sleep(0.5)
|
||||
return
|
||||
|
||||
while not _shutdown_requested.is_set():
|
||||
try:
|
||||
line = input("commonwealth> ")
|
||||
except EOFError:
|
||||
@@ -1002,7 +1133,7 @@ def _run_interactive_shell(*, admin_port: int) -> None:
|
||||
continue
|
||||
|
||||
try:
|
||||
args = shlex.split(line)
|
||||
args = shlex.split(line, posix=(os.name != "nt"))
|
||||
except ValueError as error:
|
||||
console.print(f"[red]Could not parse command: {error}[/red]")
|
||||
continue
|
||||
|
||||
+6
-4
@@ -40,8 +40,10 @@ find_pids_on_port() {
|
||||
echo "$pids"
|
||||
}
|
||||
|
||||
# Prefer python3, fall back to python
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
# Prefer local venv, then python3, then python.
|
||||
if [[ -x ".venv/bin/python" ]]; then
|
||||
PYTHON=".venv/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON=python3
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON=python
|
||||
@@ -129,9 +131,9 @@ with path.open("w", encoding="utf-8") as f:
|
||||
f.write("\n")
|
||||
PY
|
||||
|
||||
echo "Config updated. Starting server on port ${NEW_PORT}..."
|
||||
echo "Config updated. Restarting server on port ${NEW_PORT}..."
|
||||
echo
|
||||
exec "$PYTHON" consumer_server_cli.py serve --config commonwealth-server.json
|
||||
exec bash ./start.sh
|
||||
;;
|
||||
3)
|
||||
echo
|
||||
|
||||
+39
-26
@@ -21,32 +21,40 @@ class LanDiscoveryResponder:
|
||||
self._socket: socket.socket | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
discovery_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
else:
|
||||
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
discovery_socket.bind(("0.0.0.0", self._discovery_port))
|
||||
discovery_socket.settimeout(0.5)
|
||||
except OSError:
|
||||
discovery_socket.close()
|
||||
raise
|
||||
discovery_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
else:
|
||||
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
discovery_socket.bind(("0.0.0.0", self._discovery_port))
|
||||
discovery_socket.settimeout(0.5)
|
||||
except OSError as error:
|
||||
discovery_socket.close()
|
||||
raise OSError(
|
||||
f"Could not bind LAN discovery to 0.0.0.0:{self._discovery_port}. {error}"
|
||||
) from error
|
||||
|
||||
self._socket = discovery_socket
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
|
||||
self._thread.start()
|
||||
self._socket = discovery_socket
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
discovery_socket = self._socket
|
||||
self._socket = None
|
||||
with self._lock:
|
||||
self._running = False
|
||||
discovery_socket = self._socket
|
||||
self._socket = None
|
||||
thread = self._thread
|
||||
self._thread = None
|
||||
|
||||
if discovery_socket is not None:
|
||||
try:
|
||||
@@ -54,13 +62,16 @@ class LanDiscoveryResponder:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
def _listen_loop(self) -> None:
|
||||
while self._running:
|
||||
discovery_socket = self._socket
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._running:
|
||||
break
|
||||
discovery_socket = self._socket
|
||||
|
||||
if discovery_socket is None:
|
||||
break
|
||||
|
||||
@@ -69,8 +80,9 @@ class LanDiscoveryResponder:
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
if self._running:
|
||||
break
|
||||
with self._lock:
|
||||
if self._running:
|
||||
break
|
||||
continue
|
||||
|
||||
response = self._build_response(data)
|
||||
@@ -78,7 +90,8 @@ class LanDiscoveryResponder:
|
||||
continue
|
||||
|
||||
self._server._log(
|
||||
f"LAN discovery probe from {address[0]}:{address[1]} — replying with game port {response['port']}"
|
||||
f"LAN discovery probe from {address[0]}:{address[1]} — "
|
||||
f"replying with game port {response['port']}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Optional Python Host GUI / development tooling (PySide6).
|
||||
# The Windows Qt Host GUI does not require this file; it wraps the CLI server.
|
||||
# Install only when running server/dev_server_app.py.
|
||||
-r requirements-server.txt
|
||||
PySide6
|
||||
@@ -0,0 +1,4 @@
|
||||
# Dedicated CLI / headless server dependencies (Linux, macOS, Windows).
|
||||
# Do not add GUI frameworks or Windows-only packages here.
|
||||
typer==0.12.3
|
||||
rich==13.7.0
|
||||
@@ -1,3 +1,4 @@
|
||||
# Compatibility aggregate. Prefer requirements-server.txt for dedicated servers.
|
||||
# Use requirements-host-gui.txt when you need the optional PySide6 Python GUI.
|
||||
-r requirements-server.txt
|
||||
PySide6
|
||||
typer==0.12.3
|
||||
rich==13.7.0
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd -- "${ROOT}"
|
||||
|
||||
echo "==> Checking start.sh line endings (LF)"
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
data = Path("start.sh").read_bytes()
|
||||
if b"\r\n" in data or data.count(b"\r"):
|
||||
raise SystemExit("ERROR: start.sh contains CR/CRLF line endings")
|
||||
PY
|
||||
|
||||
echo "==> Checking start.sh is executable"
|
||||
test -x start.sh
|
||||
|
||||
echo "==> bash -n start.sh"
|
||||
bash -n start.sh
|
||||
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "==> shellcheck start.sh"
|
||||
shellcheck start.sh
|
||||
else
|
||||
echo "WARNING: shellcheck not installed; skipping"
|
||||
fi
|
||||
|
||||
echo "==> Creating clean virtual environment"
|
||||
rm -rf .venv-ci
|
||||
python3 -m venv .venv-ci
|
||||
.venv-ci/bin/python -m pip install --upgrade pip
|
||||
.venv-ci/bin/python -m pip install -r requirements-server.txt pytest
|
||||
|
||||
echo "==> compileall"
|
||||
.venv-ci/bin/python -m compileall -q .
|
||||
|
||||
echo "==> pytest"
|
||||
.venv-ci/bin/python -m pytest -q
|
||||
|
||||
echo "All Linux compatibility checks passed."
|
||||
+137
-59
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -35,13 +36,28 @@ _LOG_LEVELS = {
|
||||
|
||||
|
||||
def get_lan_addresses() -> list[str]:
|
||||
"""Return likely LAN IPv4 addresses for this machine."""
|
||||
"""Return likely non-loopback IPv4 addresses for this machine."""
|
||||
addresses: list[str] = []
|
||||
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
|
||||
probe.connect(("8.8.8.8", 80))
|
||||
return [probe.getsockname()[0]]
|
||||
primary = probe.getsockname()[0]
|
||||
if primary and not primary.startswith("127."):
|
||||
addresses.append(primary)
|
||||
except OSError:
|
||||
return []
|
||||
pass
|
||||
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
|
||||
candidate = info[4][0]
|
||||
if candidate and not candidate.startswith("127.") and candidate not in addresses:
|
||||
addresses.append(candidate)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return addresses
|
||||
|
||||
|
||||
class FalloutTogetherServer:
|
||||
@@ -67,6 +83,7 @@ class FalloutTogetherServer:
|
||||
self._lock = threading.RLock()
|
||||
self._log_lock = threading.RLock()
|
||||
self._clients: dict[socket.socket, ClientSession] = {}
|
||||
self._client_threads: set[threading.Thread] = set()
|
||||
self._log_listeners: list[Callable[[str], None]] = []
|
||||
self._server_socket: socket.socket | None = None
|
||||
self._accept_thread: threading.Thread | None = None
|
||||
@@ -105,8 +122,7 @@ class FalloutTogetherServer:
|
||||
return
|
||||
|
||||
self._prepare_server_socket()
|
||||
self._discovery = LanDiscoveryResponder(self)
|
||||
self._discovery.start()
|
||||
self._start_discovery()
|
||||
self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self._accept_thread.start()
|
||||
|
||||
@@ -116,20 +132,25 @@ class FalloutTogetherServer:
|
||||
raise RuntimeError("Server is already running.")
|
||||
|
||||
self._prepare_server_socket()
|
||||
self._discovery = LanDiscoveryResponder(self)
|
||||
self._discovery.start()
|
||||
self._start_discovery()
|
||||
|
||||
self._accept_loop()
|
||||
|
||||
def stop(self) -> None:
|
||||
clients: list[ClientSession]
|
||||
server_socket: socket.socket | None
|
||||
client_threads: list[threading.Thread]
|
||||
|
||||
with self._lock:
|
||||
if not self._running and self._server_socket is None and self._discovery is None:
|
||||
return
|
||||
self._running = False
|
||||
server_socket = self._server_socket
|
||||
self._server_socket = None
|
||||
clients = list(self._clients.values())
|
||||
client_threads = [
|
||||
thread for thread in self._client_threads if thread is not threading.current_thread()
|
||||
]
|
||||
|
||||
discovery = self._discovery
|
||||
self._discovery = None
|
||||
@@ -148,6 +169,13 @@ class FalloutTogetherServer:
|
||||
accept_thread = self._accept_thread
|
||||
if accept_thread is not None and accept_thread is not threading.current_thread():
|
||||
accept_thread.join(timeout=1.0)
|
||||
self._accept_thread = None
|
||||
|
||||
for thread in client_threads:
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
with self._lock:
|
||||
self._client_threads.clear()
|
||||
|
||||
def is_running(self) -> bool:
|
||||
with self._lock:
|
||||
@@ -293,12 +321,17 @@ class FalloutTogetherServer:
|
||||
try:
|
||||
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
else:
|
||||
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server_socket.bind((self.host, self.port))
|
||||
server_socket.listen()
|
||||
server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS)
|
||||
except OSError:
|
||||
except OSError as error:
|
||||
server_socket.close()
|
||||
raise
|
||||
raise OSError(
|
||||
f"Could not bind the server to {self.host}:{self.port}. "
|
||||
f"The port may already be in use or unavailable. ({error})"
|
||||
) from error
|
||||
|
||||
self._server_socket = server_socket
|
||||
self._running = True
|
||||
@@ -311,20 +344,47 @@ class FalloutTogetherServer:
|
||||
self._last_npc_state = None
|
||||
|
||||
self._log(f"Commonwealth Online server listening on {self.host}:{self.port}")
|
||||
self._log(f"LAN discovery listening on UDP port {DISCOVERY_PORT}")
|
||||
if self.host == "0.0.0.0":
|
||||
self._log(
|
||||
f"Bind address 0.0.0.0 means all interfaces; clients should not join 0.0.0.0."
|
||||
)
|
||||
self._log(f"Local clients can connect at 127.0.0.1:{self.port}")
|
||||
lan_addresses = get_lan_addresses()
|
||||
for address in lan_addresses:
|
||||
self._log(f"LAN clients can connect at {address}:{self.port}")
|
||||
if not lan_addresses:
|
||||
self._log("Could not detect a LAN IPv4 address for this machine.")
|
||||
self._log(
|
||||
"Forward this port on your router and allow it through Windows Firewall "
|
||||
"for connections from outside your network."
|
||||
)
|
||||
if sys.platform == "win32":
|
||||
self._log(
|
||||
"Forward this port on your router and allow it through Windows Firewall "
|
||||
"for connections from outside your network."
|
||||
)
|
||||
else:
|
||||
self._log(
|
||||
"Allow TCP "
|
||||
f"{self.port} through your host firewall. Router port forwarding is only "
|
||||
"needed for connections from outside your LAN."
|
||||
)
|
||||
else:
|
||||
self._log(f"Clients can connect at {self.host}:{self.port}")
|
||||
self._log("Waiting for newline-separated JSON transform packets...")
|
||||
|
||||
def _start_discovery(self) -> None:
|
||||
discovery = LanDiscoveryResponder(self)
|
||||
try:
|
||||
discovery.start()
|
||||
except OSError as error:
|
||||
self._log(
|
||||
f"LAN discovery unavailable on UDP {DISCOVERY_PORT}: {error}. "
|
||||
"Direct TCP connections still work.",
|
||||
level="warning",
|
||||
)
|
||||
self._discovery = None
|
||||
return
|
||||
|
||||
self._discovery = discovery
|
||||
self._log(f"LAN discovery listening on UDP port {DISCOVERY_PORT}")
|
||||
|
||||
def _accept_loop(self) -> None:
|
||||
try:
|
||||
while self.is_running():
|
||||
@@ -344,7 +404,13 @@ class FalloutTogetherServer:
|
||||
self._log("Server socket closed unexpectedly.")
|
||||
break
|
||||
|
||||
thread = threading.Thread(target=self._handle_client, args=(connection, address), daemon=True)
|
||||
thread = threading.Thread(
|
||||
target=self._handle_client,
|
||||
args=(connection, address),
|
||||
daemon=True,
|
||||
)
|
||||
with self._lock:
|
||||
self._client_threads.add(thread)
|
||||
thread.start()
|
||||
finally:
|
||||
with self._lock:
|
||||
@@ -407,54 +473,62 @@ class FalloutTogetherServer:
|
||||
level="debug",
|
||||
)
|
||||
|
||||
with connection:
|
||||
buffer = ""
|
||||
try:
|
||||
with connection:
|
||||
buffer = b""
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
world_state_host_player_id = self._world_state_host_player_id
|
||||
try:
|
||||
with self._lock:
|
||||
world_state_host_player_id = self._world_state_host_player_id
|
||||
|
||||
welcome_packet: dict[str, Any] = {
|
||||
"type": "welcome",
|
||||
"playerId": client.player_id,
|
||||
"serverTime": time.time(),
|
||||
"serverName": self.server_name,
|
||||
}
|
||||
if self.server_description:
|
||||
welcome_packet["serverDescription"] = self.server_description
|
||||
if world_state_host_player_id is not None:
|
||||
welcome_packet["worldStateHostPlayerId"] = world_state_host_player_id
|
||||
welcome_packet: dict[str, Any] = {
|
||||
"type": "welcome",
|
||||
"playerId": client.player_id,
|
||||
"serverTime": time.time(),
|
||||
"serverName": self.server_name,
|
||||
}
|
||||
if self.server_description:
|
||||
welcome_packet["serverDescription"] = self.server_description
|
||||
if world_state_host_player_id is not None:
|
||||
welcome_packet["worldStateHostPlayerId"] = world_state_host_player_id
|
||||
|
||||
self._send_packet(client, welcome_packet)
|
||||
# Delay transform/npc snapshots until the client sends its first
|
||||
# packet so TCP ping probes (connect+close) do not pull world state.
|
||||
self._send_packet(client, welcome_packet)
|
||||
# Delay transform/npc snapshots until the client sends its first
|
||||
# packet so TCP ping probes (connect+close) do not pull world state.
|
||||
|
||||
while self.is_running():
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
while self.is_running():
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
self._handle_line(client, line.strip())
|
||||
except ConnectionResetError:
|
||||
if client.packets_received > 0:
|
||||
self._log(
|
||||
f"Client disconnected unexpectedly: {client.label} "
|
||||
f"(player {client.player_id})"
|
||||
)
|
||||
else:
|
||||
self._log(
|
||||
f"Short-lived connection closed: {client.label} "
|
||||
f"(player {client.player_id})",
|
||||
level="debug",
|
||||
)
|
||||
except OSError as error:
|
||||
if self.is_running():
|
||||
self._log(f"Client connection error: {client.label} (player {client.player_id}): {error}")
|
||||
finally:
|
||||
self._disconnect_client(client)
|
||||
buffer += chunk
|
||||
while b"\n" in buffer:
|
||||
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||
line = line_bytes.decode("utf-8", errors="replace").strip()
|
||||
self._handle_line(client, line)
|
||||
except ConnectionResetError:
|
||||
if client.packets_received > 0:
|
||||
self._log(
|
||||
f"Client disconnected unexpectedly: {client.label} "
|
||||
f"(player {client.player_id})"
|
||||
)
|
||||
else:
|
||||
self._log(
|
||||
f"Short-lived connection closed: {client.label} "
|
||||
f"(player {client.player_id})",
|
||||
level="debug",
|
||||
)
|
||||
except OSError as error:
|
||||
if self.is_running():
|
||||
self._log(
|
||||
f"Client connection error: {client.label} "
|
||||
f"(player {client.player_id}): {error}"
|
||||
)
|
||||
finally:
|
||||
self._disconnect_client(client)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._client_threads.discard(threading.current_thread())
|
||||
|
||||
def _handle_line(self, client: ClientSession, line: str) -> None:
|
||||
if not line:
|
||||
@@ -1067,7 +1141,11 @@ class FalloutTogetherServer:
|
||||
|
||||
for listener in listeners:
|
||||
try:
|
||||
listener(message)
|
||||
# Prefer level-aware callbacks; fall back for older listeners.
|
||||
try:
|
||||
listener(message, level=level) # type: ignore[call-arg]
|
||||
except TypeError:
|
||||
listener(message)
|
||||
except Exception:
|
||||
# Future UI listeners must not be able to break server networking.
|
||||
pass
|
||||
|
||||
@@ -76,30 +76,35 @@ class ServerService:
|
||||
self.config = config or ServerConfig()
|
||||
self._server: FalloutTogetherServer | None = None
|
||||
self._admin: AdminServer | None = None
|
||||
self._log_listeners: list[Callable[[str], None]] = []
|
||||
self._log_listeners: list[Callable[..., None]] = []
|
||||
self._log_lock = threading.RLock()
|
||||
self._serve_thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
self._stop_requested = False
|
||||
|
||||
def add_log_listener(self, callback: Callable[[str], None]) -> None:
|
||||
def add_log_listener(self, callback: Callable[..., None]) -> None:
|
||||
"""Register a callback for server log messages."""
|
||||
with self._log_lock:
|
||||
if callback not in self._log_listeners:
|
||||
self._log_listeners.append(callback)
|
||||
|
||||
def remove_log_listener(self, callback: Callable[[str], None]) -> None:
|
||||
def remove_log_listener(self, callback: Callable[..., None]) -> None:
|
||||
"""Unregister a log callback."""
|
||||
with self._log_lock:
|
||||
if callback in self._log_listeners:
|
||||
self._log_listeners.remove(callback)
|
||||
|
||||
def _dispatch_log(self, message: str) -> None:
|
||||
def _dispatch_log(self, message: str, *, level: str = "info") -> None:
|
||||
"""Dispatch a log message to all registered listeners."""
|
||||
with self._log_lock:
|
||||
listeners = list(self._log_listeners)
|
||||
|
||||
for listener in listeners:
|
||||
try:
|
||||
listener(message)
|
||||
try:
|
||||
listener(message, level=level)
|
||||
except TypeError:
|
||||
listener(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -138,13 +143,14 @@ class ServerService:
|
||||
self._dispatch_log("Server is already running.")
|
||||
return
|
||||
|
||||
self._stop_requested = False
|
||||
self._server = self._create_server()
|
||||
self._server.add_log_listener(self._dispatch_log)
|
||||
self._running = True
|
||||
self._start_admin()
|
||||
|
||||
thread = threading.Thread(target=self._serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self._serve_thread = threading.Thread(target=self._serve_forever, daemon=True)
|
||||
self._serve_thread.start()
|
||||
self._dispatch_log("Server started in background thread.")
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
@@ -152,6 +158,7 @@ class ServerService:
|
||||
if self._running:
|
||||
raise RuntimeError("Server is already running.")
|
||||
|
||||
self._stop_requested = False
|
||||
self._server = self._create_server()
|
||||
self._server.add_log_listener(self._dispatch_log)
|
||||
self._running = True
|
||||
@@ -174,12 +181,22 @@ class ServerService:
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the server."""
|
||||
if not self._running or not self._server:
|
||||
if self._stop_requested and not self._running:
|
||||
return
|
||||
|
||||
self._stop_requested = True
|
||||
if not self._running and self._server is None:
|
||||
self._dispatch_log("Server is not running.")
|
||||
return
|
||||
|
||||
self._stop_admin()
|
||||
self._server.stop()
|
||||
if self._server is not None:
|
||||
self._server.stop()
|
||||
|
||||
serve_thread = self._serve_thread
|
||||
if serve_thread is not None and serve_thread is not threading.current_thread():
|
||||
serve_thread.join(timeout=2.0)
|
||||
self._serve_thread = None
|
||||
self._running = False
|
||||
self._dispatch_log("Server stopped.")
|
||||
|
||||
|
||||
+91
-13
@@ -1,13 +1,14 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo ================================================================================
|
||||
echo Commonwealth Online Server - Start Script
|
||||
echo ================================================================================
|
||||
echo.
|
||||
|
||||
REM Check if Python is installed
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python is not installed or not in PATH.
|
||||
@@ -16,24 +17,100 @@ if errorlevel 1 (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Installing dependencies...
|
||||
pip install -q typer rich 2>nul
|
||||
if errorlevel 1 (
|
||||
echo WARNING: Failed to install dependencies quietly. Retrying with output...
|
||||
pip install typer rich
|
||||
if not exist "requirements-server.txt" (
|
||||
echo ERROR: Missing requirements-server.txt
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "UPDATE_DEPENDENCIES=0"
|
||||
set "CONFIG_FILE=%cd%\commonwealth-server.json"
|
||||
set "SERVER_ARGS="
|
||||
:parse_args
|
||||
if "%~1"=="" goto args_done
|
||||
if /I "%~1"=="--update-dependencies" (
|
||||
set "UPDATE_DEPENDENCIES=1"
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
if /I "%~1"=="--config" (
|
||||
if "%~2"=="" (
|
||||
echo ERROR: --config requires a config file path.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set "CONFIG_FILE=%~f2"
|
||||
shift
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
if /I "%~1"=="-c" (
|
||||
if "%~2"=="" (
|
||||
echo ERROR: -c requires a config file path.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set "CONFIG_FILE=%~f2"
|
||||
shift
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
echo %~1| findstr /I /R "\.json$" >nul
|
||||
if not errorlevel 1 (
|
||||
set "CONFIG_FILE=%~f1"
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
set "SERVER_ARGS=!SERVER_ARGS! %1"
|
||||
shift
|
||||
goto parse_args
|
||||
:args_done
|
||||
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Creating virtual environment at .venv...
|
||||
python -m venv .venv
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to create virtual environment.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
set "VENV_PYTHON=.venv\Scripts\python.exe"
|
||||
if not exist "%VENV_PYTHON%" (
|
||||
echo ERROR: Virtual environment interpreter missing.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "NEED_INSTALL=0"
|
||||
if not exist ".venv\.requirements-server.sha256" set "NEED_INSTALL=1"
|
||||
if "%UPDATE_DEPENDENCIES%"=="1" set "NEED_INSTALL=1"
|
||||
|
||||
if "%NEED_INSTALL%"=="0" (
|
||||
"%VENV_PYTHON%" -c "from hashlib import sha256; from pathlib import Path; expected=Path('.venv/.requirements-server.sha256').read_text(encoding='utf-8').strip(); actual=sha256(Path('requirements-server.txt').read_bytes()).hexdigest(); raise SystemExit(0 if expected==actual else 1)"
|
||||
if errorlevel 1 set "NEED_INSTALL=1"
|
||||
)
|
||||
|
||||
if "%NEED_INSTALL%"=="1" (
|
||||
echo Installing dedicated-server dependencies into .venv...
|
||||
"%VENV_PYTHON%" -m pip install --upgrade pip >nul 2>&1
|
||||
"%VENV_PYTHON%" -m pip install -r requirements-server.txt
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to install required packages.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
"%VENV_PYTHON%" -c "from hashlib import sha256; from pathlib import Path; Path('.venv/.requirements-server.sha256').write_text(sha256(Path('requirements-server.txt').read_bytes()).hexdigest() + chr(10), encoding='utf-8')"
|
||||
echo Dependencies installed.
|
||||
) else (
|
||||
echo Dependencies are up to date.
|
||||
)
|
||||
echo Dependencies installed.
|
||||
echo.
|
||||
|
||||
REM Check if config file exists
|
||||
if not exist "commonwealth-server.json" (
|
||||
if not exist "!CONFIG_FILE!" (
|
||||
echo Generating default configuration file...
|
||||
python consumer_server_cli.py config init commonwealth-server.json
|
||||
"%VENV_PYTHON%" -u consumer_server_cli.py config init "!CONFIG_FILE!"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to generate config file.
|
||||
pause
|
||||
@@ -46,10 +123,10 @@ echo Starting Commonwealth Online Server...
|
||||
echo Type help for commands. Type quit or press Ctrl+C to stop.
|
||||
echo.
|
||||
|
||||
python consumer_server_cli.py serve --config commonwealth-server.json --interactive
|
||||
"%VENV_PYTHON%" -u consumer_server_cli.py serve --config "!CONFIG_FILE!" --interactive !SERVER_ARGS!
|
||||
set "EXIT_CODE=!ERRORLEVEL!"
|
||||
|
||||
REM Check if the error was port in use
|
||||
if errorlevel 1 (
|
||||
if not "!EXIT_CODE!"=="0" (
|
||||
echo.
|
||||
echo Server failed to start. If you see "Only one usage of each socket address"
|
||||
echo error, the port may already be in use.
|
||||
@@ -62,3 +139,4 @@ if errorlevel 1 (
|
||||
)
|
||||
|
||||
pause
|
||||
exit /b !EXIT_CODE!
|
||||
|
||||
+192
-60
@@ -1,7 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
SCRIPT_PATH="$(readlink -f -- "${BASH_SOURCE[0]}" 2>/dev/null || true)"
|
||||
if [[ -z "${SCRIPT_PATH}" ]]; then
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
else
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${SCRIPT_PATH}")" && pwd)"
|
||||
fi
|
||||
cd -- "${SCRIPT_DIR}"
|
||||
|
||||
SERVER_DIR="${SCRIPT_DIR}"
|
||||
VENV_DIR="${SERVER_DIR}/.venv"
|
||||
REQUIREMENTS_FILE="${SERVER_DIR}/requirements-server.txt"
|
||||
REQUIREMENTS_HASH_FILE="${VENV_DIR}/.requirements-server.sha256"
|
||||
CONFIG_FILE="${SERVER_DIR}/commonwealth-server.json"
|
||||
ENTRY_POINT="${SERVER_DIR}/consumer_server_cli.py"
|
||||
|
||||
UPDATE_DEPENDENCIES=0
|
||||
SERVER_ARGS=()
|
||||
ARGS=("$@")
|
||||
ARG_INDEX=0
|
||||
while [[ ${ARG_INDEX} -lt ${#ARGS[@]} ]]; do
|
||||
arg="${ARGS[${ARG_INDEX}]}"
|
||||
case "${arg}" in
|
||||
--update-dependencies)
|
||||
UPDATE_DEPENDENCIES=1
|
||||
;;
|
||||
--config|-c)
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then
|
||||
echo "ERROR: ${arg} requires a config file path." >&2
|
||||
exit 1
|
||||
fi
|
||||
CONFIG_FILE="${ARGS[${ARG_INDEX}]}"
|
||||
;;
|
||||
--config=*)
|
||||
CONFIG_FILE="${arg#--config=}"
|
||||
;;
|
||||
--host|--port|-H|-p|--interactive|-i)
|
||||
SERVER_ARGS+=("${arg}")
|
||||
if [[ "${arg}" == "--host" || "${arg}" == "-H" || "${arg}" == "--port" || "${arg}" == "-p" ]]; then
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
if [[ ${ARG_INDEX} -ge ${#ARGS[@]} ]]; then
|
||||
echo "ERROR: ${arg} requires a value." >&2
|
||||
exit 1
|
||||
fi
|
||||
SERVER_ARGS+=("${ARGS[${ARG_INDEX}]}")
|
||||
fi
|
||||
;;
|
||||
--host=*|--port=*)
|
||||
SERVER_ARGS+=("${arg}")
|
||||
;;
|
||||
*.json)
|
||||
# File managers / "Open with" often pass the config path as $1.
|
||||
CONFIG_FILE="${arg}"
|
||||
;;
|
||||
-*)
|
||||
echo "ERROR: Unknown option: ${arg}" >&2
|
||||
echo "Supported: --update-dependencies, --config PATH, --host HOST, --port PORT, --interactive" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [[ -f "${arg}" ]]; then
|
||||
CONFIG_FILE="${arg}"
|
||||
else
|
||||
echo "ERROR: Unexpected argument: ${arg}" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
ARG_INDEX=$((ARG_INDEX + 1))
|
||||
done
|
||||
|
||||
echo
|
||||
echo "================================================================================"
|
||||
@@ -9,72 +78,135 @@ echo " Commonwealth Online Server - Start Script"
|
||||
echo "================================================================================"
|
||||
echo
|
||||
|
||||
# Prefer python3, fall back to python
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON=python3
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON=python
|
||||
else
|
||||
echo "ERROR: Python is not installed or not in PATH."
|
||||
echo "Please install Python 3.9+ from https://www.python.org"
|
||||
exit 1
|
||||
fi
|
||||
die() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! "$PYTHON" --version >/dev/null 2>&1; then
|
||||
echo "ERROR: Python is not installed or not in PATH."
|
||||
echo "Please install Python 3.9+ from https://www.python.org"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v pip3 >/dev/null 2>&1; then
|
||||
PIP=(pip3)
|
||||
elif command -v pip >/dev/null 2>&1; then
|
||||
PIP=(pip)
|
||||
else
|
||||
PIP=("$PYTHON" -m pip)
|
||||
fi
|
||||
|
||||
echo "Installing dependencies..."
|
||||
if ! "${PIP[@]}" install -q typer rich 2>/dev/null; then
|
||||
echo "WARNING: Failed to install dependencies quietly. Retrying with output..."
|
||||
if ! "${PIP[@]}" install typer rich; then
|
||||
echo "ERROR: Failed to install required packages."
|
||||
exit 1
|
||||
detect_python() {
|
||||
local candidate
|
||||
for candidate in python3 python; do
|
||||
if command -v "${candidate}" >/dev/null 2>&1; then
|
||||
if "${candidate}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
|
||||
echo "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ ! -f "${REQUIREMENTS_FILE}" ]]; then
|
||||
die "Missing requirements file: ${REQUIREMENTS_FILE}"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ENTRY_POINT}" ]]; then
|
||||
die "Missing server entry point: ${ENTRY_POINT}"
|
||||
fi
|
||||
|
||||
if ! BASE_PYTHON="$(detect_python)"; then
|
||||
die "Python 3.9+ is required. Install python3 (and python3-venv on Debian/Ubuntu)."
|
||||
fi
|
||||
|
||||
echo "Using system interpreter: ${BASE_PYTHON} ($("${BASE_PYTHON}" --version 2>&1))"
|
||||
|
||||
if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
|
||||
echo "Creating virtual environment at ${VENV_DIR}..."
|
||||
if ! "${BASE_PYTHON}" -m venv "${VENV_DIR}"; then
|
||||
die "Failed to create virtual environment. On Debian/Ubuntu install python3-venv."
|
||||
fi
|
||||
fi
|
||||
|
||||
VENV_PYTHON="${VENV_DIR}/bin/python"
|
||||
if [[ ! -x "${VENV_PYTHON}" ]]; then
|
||||
die "Virtual environment interpreter missing: ${VENV_PYTHON}"
|
||||
fi
|
||||
|
||||
if ! "${VENV_PYTHON}" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
|
||||
die "Virtual environment Python is older than 3.9."
|
||||
fi
|
||||
|
||||
hash_requirements() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum -- "${REQUIREMENTS_FILE}" | awk '{print $1}'
|
||||
else
|
||||
"${VENV_PYTHON}" - <<'PY'
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
print(sha256(Path("requirements-server.txt").read_bytes()).hexdigest())
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
CURRENT_HASH="$(hash_requirements)"
|
||||
STORED_HASH=""
|
||||
if [[ -f "${REQUIREMENTS_HASH_FILE}" ]]; then
|
||||
STORED_HASH="$(tr -d '[:space:]' < "${REQUIREMENTS_HASH_FILE}")"
|
||||
fi
|
||||
|
||||
NEED_INSTALL=0
|
||||
if [[ ! -f "${REQUIREMENTS_HASH_FILE}" ]]; then
|
||||
NEED_INSTALL=1
|
||||
elif [[ "${CURRENT_HASH}" != "${STORED_HASH}" ]]; then
|
||||
NEED_INSTALL=1
|
||||
elif [[ "${UPDATE_DEPENDENCIES}" -eq 1 ]]; then
|
||||
NEED_INSTALL=1
|
||||
fi
|
||||
|
||||
if [[ "${NEED_INSTALL}" -eq 1 ]]; then
|
||||
echo "Installing dedicated-server dependencies into .venv..."
|
||||
if ! "${VENV_PYTHON}" -m pip install --upgrade pip >/dev/null 2>&1; then
|
||||
echo "WARNING: Could not upgrade pip quietly; continuing with existing pip."
|
||||
fi
|
||||
if ! "${VENV_PYTHON}" -m pip install -r "${REQUIREMENTS_FILE}"; then
|
||||
die "Failed to install dependencies from ${REQUIREMENTS_FILE}."
|
||||
fi
|
||||
printf '%s\n' "${CURRENT_HASH}" > "${REQUIREMENTS_HASH_FILE}"
|
||||
echo "Dependencies installed."
|
||||
else
|
||||
echo "Dependencies are up to date."
|
||||
fi
|
||||
echo "Dependencies installed."
|
||||
echo
|
||||
|
||||
if [[ ! -f commonwealth-server.json ]]; then
|
||||
echo "Generating default configuration file..."
|
||||
if ! "$PYTHON" consumer_server_cli.py config init commonwealth-server.json; then
|
||||
echo "ERROR: Failed to generate config file."
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
if [[ ! -f "${CONFIG_FILE}" ]]; then
|
||||
echo "Generating default configuration file..."
|
||||
if ! "${VENV_PYTHON}" -u "${ENTRY_POINT}" config init "${CONFIG_FILE}"; then
|
||||
die "Failed to generate config file."
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "Starting Commonwealth Online Server..."
|
||||
echo "Type help for commands. Type quit or press Ctrl+C to stop."
|
||||
INTERACTIVE_ARGS=()
|
||||
if [[ -t 0 && -t 1 ]]; then
|
||||
INTERACTIVE_ARGS+=(--interactive)
|
||||
echo "Starting Commonwealth Online Server (interactive)..."
|
||||
echo "Type help for commands. Type quit or press Ctrl+C to stop."
|
||||
else
|
||||
echo "Starting Commonwealth Online Server (non-interactive)..."
|
||||
echo "Manage the server with: ${VENV_PYTHON} -u consumer_server_cli.py status"
|
||||
fi
|
||||
echo
|
||||
|
||||
set +e
|
||||
"$PYTHON" consumer_server_cli.py serve --config commonwealth-server.json --interactive
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo
|
||||
echo "Server failed to start. If you see an address already in use"
|
||||
echo "error, the port may already be in use."
|
||||
echo
|
||||
read -r -p "Would you like to fix the port issue? (y/N): " answer
|
||||
case "$answer" in
|
||||
[yY]|[yY][eE][sS])
|
||||
echo
|
||||
exec bash ./fix-port.sh
|
||||
;;
|
||||
esac
|
||||
# Resolve config to an absolute path after cd'ing into the server directory.
|
||||
if [[ "${CONFIG_FILE}" != /* ]]; then
|
||||
CONFIG_FILE="${SERVER_DIR}/${CONFIG_FILE}"
|
||||
fi
|
||||
CONFIG_FILE="$(cd -- "$(dirname -- "${CONFIG_FILE}")" && pwd)/$(basename -- "${CONFIG_FILE}")"
|
||||
|
||||
exit "$exit_code"
|
||||
# Do not source .venv/bin/activate — invoke the venv interpreter directly.
|
||||
# Always pass --config explicitly so a bare path is never a positional serve arg.
|
||||
CMD=(
|
||||
"${VENV_PYTHON}"
|
||||
-u
|
||||
"${ENTRY_POINT}"
|
||||
serve
|
||||
--config
|
||||
"${CONFIG_FILE}"
|
||||
)
|
||||
if [[ ${#INTERACTIVE_ARGS[@]} -gt 0 ]]; then
|
||||
CMD+=("${INTERACTIVE_ARGS[@]}")
|
||||
fi
|
||||
if [[ ${#SERVER_ARGS[@]} -gt 0 ]]; then
|
||||
CMD+=("${SERVER_ARGS[@]}")
|
||||
fi
|
||||
exec "${CMD[@]}"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from config import (
|
||||
Config,
|
||||
ensure_writable_directory,
|
||||
load_config,
|
||||
save_config,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
def test_save_and_load_utf8_lf(tmp_path: Path) -> None:
|
||||
path = tmp_path / "commonwealth-server.json"
|
||||
cfg = Config(
|
||||
host="0.0.0.0",
|
||||
port=7777,
|
||||
server_name="Café Server",
|
||||
server_description="résumé",
|
||||
max_players=8,
|
||||
log_verbosity="info",
|
||||
admin_port=7779,
|
||||
)
|
||||
save_config(cfg, str(path))
|
||||
|
||||
raw = path.read_bytes()
|
||||
assert b"\r\n" not in raw
|
||||
assert "Café Server".encode("utf-8") in raw
|
||||
|
||||
loaded = load_config(str(path))
|
||||
assert loaded.server_name == "Café Server"
|
||||
assert loaded.server_description == "résumé"
|
||||
assert loaded.admin_port == 7779
|
||||
|
||||
|
||||
def test_validate_rejects_bad_ports_and_max_players() -> None:
|
||||
cfg = Config(port=70000, admin_port=7777, max_players=0)
|
||||
ok, errors = validate_config(cfg)
|
||||
assert not ok
|
||||
assert any("port must be 1-65535" in error for error in errors)
|
||||
assert any("max_players must be >= 1" in error for error in errors)
|
||||
|
||||
collide = Config(port=7777, admin_port=7777)
|
||||
ok, errors = validate_config(collide)
|
||||
assert not ok
|
||||
assert any("admin_port must differ" in error for error in errors)
|
||||
|
||||
|
||||
def test_validate_rejects_unresolvable_host() -> None:
|
||||
cfg = Config(host="this-host-should-not-resolve.invalid")
|
||||
ok, errors = validate_config(cfg)
|
||||
assert not ok
|
||||
assert any("not a valid IPv4" in error for error in errors)
|
||||
|
||||
|
||||
def test_ensure_writable_directory(tmp_path: Path) -> None:
|
||||
target = tmp_path / "state"
|
||||
ensure_writable_directory(target)
|
||||
assert target.is_dir()
|
||||
assert not (target / ".commonwealth-write-probe").exists()
|
||||
|
||||
|
||||
def test_load_invalid_json(tmp_path: Path) -> None:
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
with pytest.raises(Exception):
|
||||
load_config(str(path))
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from admin_server import send_admin_command
|
||||
from lan_discovery import LanDiscoveryResponder
|
||||
from server_core import FalloutTogetherServer, get_lan_addresses
|
||||
from server_service import ServerConfig, ServerService
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def test_get_lan_addresses_never_returns_wildcard() -> None:
|
||||
addresses = get_lan_addresses()
|
||||
assert "0.0.0.0" not in addresses
|
||||
for address in addresses:
|
||||
assert not address.startswith("127.")
|
||||
|
||||
|
||||
def test_discovery_failure_does_not_stop_game_server(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
game_port = _free_port()
|
||||
admin_port = _free_port()
|
||||
|
||||
def _fail_start(self: LanDiscoveryResponder) -> None:
|
||||
raise OSError("Could not bind LAN discovery to 0.0.0.0:7778. simulated failure")
|
||||
|
||||
monkeypatch.setattr(LanDiscoveryResponder, "start", _fail_start)
|
||||
|
||||
service = ServerService(
|
||||
ServerConfig(
|
||||
host="127.0.0.1",
|
||||
port=game_port,
|
||||
admin_port=admin_port,
|
||||
bans_path=str(tmp_path / "bans.json"),
|
||||
)
|
||||
)
|
||||
thread = threading.Thread(target=service.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline and not service.is_running():
|
||||
time.sleep(0.05)
|
||||
|
||||
assert service.is_running()
|
||||
with socket.create_connection(("127.0.0.1", game_port), timeout=2.0) as conn:
|
||||
data = conn.recv(4096)
|
||||
assert b"welcome" in data
|
||||
|
||||
service.stop()
|
||||
thread.join(timeout=3.0)
|
||||
assert not service.is_running()
|
||||
|
||||
|
||||
def test_stop_is_idempotent(tmp_path: Path) -> None:
|
||||
game_port = _free_port()
|
||||
admin_port = _free_port()
|
||||
service = ServerService(
|
||||
ServerConfig(
|
||||
host="127.0.0.1",
|
||||
port=game_port,
|
||||
admin_port=admin_port,
|
||||
bans_path=str(tmp_path / "bans.json"),
|
||||
)
|
||||
)
|
||||
service.start()
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline and not service.is_running():
|
||||
time.sleep(0.05)
|
||||
assert service.is_running()
|
||||
|
||||
service.stop()
|
||||
service.stop()
|
||||
assert not service.is_running()
|
||||
|
||||
|
||||
def test_admin_and_game_bind_errors_include_address() -> None:
|
||||
occupied = _free_port()
|
||||
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
holder.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
holder.bind(("127.0.0.1", occupied))
|
||||
holder.listen()
|
||||
|
||||
server = FalloutTogetherServer(host="127.0.0.1", port=occupied)
|
||||
with pytest.raises(OSError, match=rf"127\.0\.0\.1:{occupied}"):
|
||||
server._prepare_server_socket()
|
||||
finally:
|
||||
holder.close()
|
||||
|
||||
|
||||
def test_headless_admin_roundtrip(tmp_path: Path) -> None:
|
||||
game_port = _free_port()
|
||||
admin_port = _free_port()
|
||||
service = ServerService(
|
||||
ServerConfig(
|
||||
host="127.0.0.1",
|
||||
port=game_port,
|
||||
admin_port=admin_port,
|
||||
bans_path=str(tmp_path / "bans.json"),
|
||||
)
|
||||
)
|
||||
thread = threading.Thread(target=service.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline and not service.is_running():
|
||||
time.sleep(0.05)
|
||||
assert service.is_running()
|
||||
|
||||
response = send_admin_command({"cmd": "ping"}, port=admin_port)
|
||||
assert response.get("ok") is True
|
||||
assert response.get("data", {}).get("pong") is True
|
||||
|
||||
service.stop()
|
||||
thread.join(timeout=3.0)
|
||||
|
||||
|
||||
def test_lan_discovery_sets_broadcast_option() -> None:
|
||||
class DummyServer:
|
||||
def get_stats(self):
|
||||
return {
|
||||
"connectedClients": 0,
|
||||
"port": 7777,
|
||||
"serverName": "Test",
|
||||
"serverDescription": "",
|
||||
"maxPlayers": 16,
|
||||
}
|
||||
|
||||
def _log(self, message: str, *, level: str = "info") -> None:
|
||||
return None
|
||||
|
||||
# Bind an ephemeral discovery port to avoid colliding with a real server.
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = int(sock.getsockname()[1])
|
||||
sock.close()
|
||||
|
||||
responder = LanDiscoveryResponder(DummyServer(), discovery_port=port)
|
||||
responder.start()
|
||||
try:
|
||||
assert responder._socket is not None
|
||||
# SO_BROADCAST should be enabled; querying may return 0/1 depending on OS.
|
||||
value = responder._socket.getsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST)
|
||||
assert value in (0, 1)
|
||||
probe = {
|
||||
"type": "discover",
|
||||
"protocol": "commonwealth-online",
|
||||
}
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
|
||||
client.settimeout(2.0)
|
||||
client.sendto(json.dumps(probe).encode("utf-8"), ("127.0.0.1", port))
|
||||
data, _addr = client.recvfrom(2048)
|
||||
packet = json.loads(data.decode("utf-8"))
|
||||
assert packet["type"] == "discoverResponse"
|
||||
assert packet["port"] == 7777
|
||||
finally:
|
||||
responder.stop()
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import consumer_server_cli
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_serve_accepts_positional_config_path(tmp_path: Path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1,
|
||||
"server_name": "Arg Test",
|
||||
"max_players": 2,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 2,
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
# Avoid binding real sockets; validate CLI parsing only.
|
||||
called: dict[str, object] = {}
|
||||
|
||||
class FakeService:
|
||||
def __init__(self) -> None:
|
||||
self.config = None
|
||||
|
||||
def add_log_listener(self, _callback) -> None:
|
||||
return None
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
called["served"] = True
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(consumer_server_cli, "get_service", FakeService)
|
||||
monkeypatch.setattr(consumer_server_cli, "print_startup_banner", lambda _cfg: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "ensure_writable_directory", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
consumer_server_cli,
|
||||
"validate_config",
|
||||
lambda _cfg: (True, []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
consumer_server_cli.signal,
|
||||
"signal",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
consumer_server_cli.app,
|
||||
["serve", str(config_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert called.get("served") is True
|
||||
|
||||
|
||||
def test_serve_accepts_config_option(tmp_path: Path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1,
|
||||
"server_name": "Arg Test",
|
||||
"max_players": 2,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": 2,
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
called: dict[str, object] = {}
|
||||
|
||||
class FakeService:
|
||||
def __init__(self) -> None:
|
||||
self.config = None
|
||||
|
||||
def add_log_listener(self, _callback) -> None:
|
||||
return None
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
called["served"] = True
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(consumer_server_cli, "get_service", FakeService)
|
||||
monkeypatch.setattr(consumer_server_cli, "print_startup_banner", lambda _cfg: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "ensure_writable_directory", lambda _path: None)
|
||||
monkeypatch.setattr(consumer_server_cli, "validate_config", lambda _cfg: (True, []))
|
||||
monkeypatch.setattr(consumer_server_cli.signal, "signal", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = runner.invoke(
|
||||
consumer_server_cli.app,
|
||||
["serve", "--config", str(config_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert called.get("served") is True
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name == "nt", reason="SIGTERM integration is POSIX-only")
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def test_sigterm_exits_cleanly(tmp_path: Path) -> None:
|
||||
game_port = _free_port()
|
||||
admin_port = _free_port()
|
||||
config_path = tmp_path / "commonwealth-server.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": game_port,
|
||||
"server_name": "CI Test Server",
|
||||
"server_description": "",
|
||||
"max_players": 4,
|
||||
"log_verbosity": "info",
|
||||
"admin_port": admin_port,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["NO_COLOR"] = "1"
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-u",
|
||||
str(SERVER_DIR / "consumer_server_cli.py"),
|
||||
"serve",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(SERVER_DIR),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
try:
|
||||
deadline = time.time() + 10.0
|
||||
connected = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", game_port), timeout=0.5) as conn:
|
||||
welcome = conn.recv(4096)
|
||||
if b"welcome" in welcome:
|
||||
connected = True
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
assert connected, "server did not accept a test client in time"
|
||||
|
||||
# Admin probe over localhost only.
|
||||
with socket.create_connection(("127.0.0.1", admin_port), timeout=2.0) as admin:
|
||||
admin.sendall(b'{"cmd":"ping"}\n')
|
||||
response = admin.recv(4096)
|
||||
assert b'"ok":true' in response.replace(b" ", b"")
|
||||
|
||||
process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
exit_code = process.wait(timeout=10.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
pytest.fail("server did not exit after SIGTERM")
|
||||
|
||||
assert exit_code == 0
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5.0)
|
||||
Reference in New Issue
Block a user