Complete C# server cutover

This commit is contained in:
Nomads_Reach
2026-08-16 02:36:32 -04:00
parent ddadf87423
commit 757e614cdd
79 changed files with 923 additions and 13129 deletions
+4 -4
View File
@@ -1,9 +1,9 @@
# Normalize text files by default; platform-specific EOL below.
* text=auto
# Shell / service / config (LF)
# LF text
*.sh text eol=lf
*.py text eol=lf
*.cs text eol=lf
*.csproj text eol=lf
*.service text eol=lf
*.conf text eol=lf
*.json text eol=lf
@@ -20,7 +20,7 @@
*.hpp text eol=lf
CMakeLists.txt text eol=lf
# Windows scripts (CRLF)
# Windows scripts
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
+33 -8
View File
@@ -5,35 +5,60 @@ on:
branches:
- rewrite/csharp-server
paths:
- "server/**/*.cs"
- "server/**/*.csproj"
- "server/**"
- "src/ServerProcess.*"
- "CMakeLists.txt"
- "cmake/stage_server.cmake"
- "build.bat"
- "deploy.bat"
- "check-setup.bat"
- ".github/workflows/csharp-server.yml"
workflow_dispatch:
jobs:
build-and-test:
runs-on: [self-hosted, Linux, X64]
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- name: Reject Python files
shell: bash
run: |
if find . -type f -name '*.py' -print -quit | grep -q .; then
echo "Python files are not allowed in CO-SERVER"
find . -type f -name '*.py' -print
exit 1
fi
if find server -maxdepth 1 -type f -name 'requirements*.txt' -print -quit | grep -q .; then
echo "Python requirements files are not allowed in CO-SERVER"
find server -maxdepth 1 -type f -name 'requirements*.txt' -print
exit 1
fi
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Report .NET
run: dotnet --info
- name: Build C# server
working-directory: server
run: dotnet build CommonwealthOnline.Server.csproj -c Release --nologo
- name: Run C# server tests
working-directory: server
run: dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release --no-restore
- name: Check Linux scripts
working-directory: server
run: |
chmod +x start.sh fix-port.sh scripts/linux_compat_checks.sh
bash -n start.sh
bash -n fix-port.sh
bash -n scripts/linux_compat_checks.sh
- name: Publish Linux server
working-directory: server
run: dotnet publish CommonwealthOnline.Server.csproj -c Release -r linux-x64 --self-contained false -o publish/linux-x64 --nologo
- name: Verify published entrypoint
working-directory: server
run: test -f publish/linux-x64/CommonwealthOnline.Server.dll
-113
View File
@@ -1,113 +0,0 @@
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 line endings
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
# GitHub Contents API commits cannot set the executable mode. Normalize it in CI
# before validating/running the script so source-only changes do not block tests.
chmod +x start.sh
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 line endings
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
# Normalize the mode for the same reason as the Ubuntu job above.
chmod +x start.sh
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
@@ -1,93 +0,0 @@
name: Networking V2 Self-Hosted Gate
on:
push:
paths:
- "server/client_session.py"
- "server/config.py"
- "server/packet_codec.py"
- "server/tcp_transport.py"
- "server/transport_server.py"
- "server/gns_transport.py"
- "server/gns_gameplay_adapter.py"
- "server/gns_snapshot_envelope.py"
- "server/server_service.py"
- "server/snapshot_sequence.py"
- "server/transport_policy.py"
- "server/tests/test_config_portability.py"
- "server/tests/test_packet_codec.py"
- "server/tests/test_tcp_transport.py"
- "server/tests/test_transport_server.py"
- "server/tests/test_gns_transport.py"
- "server/tests/test_gns_gameplay_adapter.py"
- "server/tests/test_gns_snapshot_envelope.py"
- "server/tests/test_server_service_transport.py"
- "server/tests/test_snapshot_sequence.py"
- "server/tests/test_transport_policy.py"
- ".github/workflows/networking-v2-selfhosted.yml"
pull_request:
paths:
- "server/client_session.py"
- "server/config.py"
- "server/packet_codec.py"
- "server/tcp_transport.py"
- "server/transport_server.py"
- "server/gns_transport.py"
- "server/gns_gameplay_adapter.py"
- "server/gns_snapshot_envelope.py"
- "server/server_service.py"
- "server/snapshot_sequence.py"
- "server/transport_policy.py"
- "server/tests/test_config_portability.py"
- "server/tests/test_packet_codec.py"
- "server/tests/test_tcp_transport.py"
- "server/tests/test_transport_server.py"
- "server/tests/test_gns_transport.py"
- "server/tests/test_gns_gameplay_adapter.py"
- "server/tests/test_gns_snapshot_envelope.py"
- "server/tests/test_server_service_transport.py"
- "server/tests/test_snapshot_sequence.py"
- "server/tests/test_transport_policy.py"
- ".github/workflows/networking-v2-selfhosted.yml"
jobs:
transport-modules:
name: Transport modules
runs-on: [self-hosted, Linux, X64]
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- name: Report Python
run: python3 --version
- name: Byte compile transport modules
run: |
python3 -m compileall -q \
client_session.py \
config.py \
packet_codec.py \
tcp_transport.py \
transport_server.py \
gns_transport.py \
gns_gameplay_adapter.py \
gns_snapshot_envelope.py \
server_service.py \
snapshot_sequence.py \
transport_policy.py
- name: Run transport regression tests
run: |
python3 -m pytest -q \
tests/test_config_portability.py \
tests/test_packet_codec.py \
tests/test_tcp_transport.py \
tests/test_transport_server.py \
tests/test_gns_transport.py \
tests/test_gns_gameplay_adapter.py \
tests/test_gns_snapshot_envelope.py \
tests/test_server_service_transport.py \
tests/test_snapshot_sequence.py \
tests/test_transport_policy.py
+12 -11
View File
@@ -1,6 +1,12 @@
# Build outputs
build/
out/
publish/
server/bin/
server/obj/
server/publish/
server/tests/bin/
server/tests/obj/
cmake-build-*/
*.exe
*.dll
@@ -8,6 +14,8 @@ cmake-build-*/
*.obj
*.o
*.a
*.so
*.dylib
*.pdb
*.ilk
*.exp
@@ -34,17 +42,10 @@ ui_*.h
*.rcc
qrc_*.cpp
# Python (source + staged server copies)
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
.venv-ci/
venv/
env/
# .NET generated
*.deps.json
*.runtimeconfig.json
TestResults/
# Local / runtime artifacts
*.log
+30 -34
View File
@@ -3,19 +3,12 @@ project(CommonwealthOnlineHost)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 COMPONENTS
Core
Gui
Widgets
Network
Concurrent
REQUIRED
)
find_package(Qt6 COMPONENTS Core Gui Widgets Network Concurrent REQUIRED)
find_program(DOTNET_EXECUTABLE dotnet REQUIRED)
set(PROJECT_SOURCES
src/main.cpp
@@ -29,44 +22,47 @@ set(PROJECT_SOURCES
)
add_executable(CommonwealthOnlineHost ${PROJECT_SOURCES})
target_link_libraries(CommonwealthOnlineHost Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network Qt6::Concurrent)
target_link_libraries(CommonwealthOnlineHost
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::Network
Qt6::Concurrent
)
# Windows-specific settings
if(WIN32)
set_target_properties(CommonwealthOnlineHost PROPERTIES
WIN32_EXECUTABLE ON
VS_DPI_AWARE "ON"
)
set_target_properties(CommonwealthOnlineHost PROPERTIES WIN32_EXECUTABLE ON VS_DPI_AWARE "ON")
set(CO_SERVER_RID "win-x64")
set(CO_SERVER_EXECUTABLE_SUFFIX ".exe")
else()
set(CO_SERVER_RID "linux-x64")
set(CO_SERVER_EXECUTABLE_SUFFIX "")
endif()
# Set output directory
set_target_properties(CommonwealthOnlineHost PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)
set_target_properties(CommonwealthOnlineHost PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
# Stage the Python relay server next to the Host GUI executable so
# ServerProcess can find consumer_server_cli.py at runtime.
# Preserve a local commonwealth-server.json if the host already edited it.
set(CO_SERVER_SOURCE_DIR "${CMAKE_SOURCE_DIR}/server")
set(CO_SERVER_PROJECT "${CO_SERVER_SOURCE_DIR}/CommonwealthOnline.Server.csproj")
set(CO_SERVER_PUBLISH_DIR "${CMAKE_BINARY_DIR}/server-publish/${CO_SERVER_RID}")
set(CO_SERVER_STAGE_DIR "$<TARGET_FILE_DIR:CommonwealthOnlineHost>/server")
set(CO_SERVER_STAGE_CONFIG "${CO_SERVER_STAGE_DIR}/commonwealth-server.json")
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
message(FATAL_ERROR
"Bundled server source not found at ${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
if(NOT EXISTS "${CO_SERVER_PROJECT}")
message(FATAL_ERROR "Bundled C# server project not found at ${CO_SERVER_PROJECT}")
endif()
add_custom_command(TARGET CommonwealthOnlineHost POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "${CO_SERVER_STAGE_DIR}"
COMMAND ${CMAKE_COMMAND} -E rm -rf "${CO_SERVER_PUBLISH_DIR}"
COMMAND ${DOTNET_EXECUTABLE} publish "${CO_SERVER_PROJECT}"
-c Release
-r ${CO_SERVER_RID}
--self-contained true
-p:PublishSingleFile=true
-p:DebugType=None
-p:DebugSymbols=false
-o "${CO_SERVER_PUBLISH_DIR}"
--nologo
COMMAND ${CMAKE_COMMAND}
-DCO_SERVER_SOURCE_DIR=${CO_SERVER_SOURCE_DIR}
-DCO_SERVER_PUBLISH_DIR=${CO_SERVER_PUBLISH_DIR}
-DCO_SERVER_STAGE_DIR=${CO_SERVER_STAGE_DIR}
-DCO_SERVER_STAGE_CONFIG=${CO_SERVER_STAGE_CONFIG}
-DCO_SERVER_EXECUTABLE_SUFFIX=${CO_SERVER_EXECUTABLE_SUFFIX}
-P "${CMAKE_SOURCE_DIR}/cmake/stage_server.cmake"
COMMENT "Copying Python server next to CommonwealthOnlineHost.exe"
COMMENT "Publishing and staging C# Commonwealth Online server"
VERBATIM
)
+43 -199
View File
@@ -1,237 +1,81 @@
# Commonwealth Online GUI - Deployment Guide
# Deployment
## Quick Start - Running the GUI
## Dedicated Linux server
### First Time Setup (Deploy Qt DLLs)
The executable needs Qt6 runtime libraries. Deploy them once:
Publish a self-contained server:
```bash
cd host-gui
deploy.bat
cd server
dotnet publish CommonwealthOnline.Server.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-p:PublishSingleFile=true \
-o publish/linux-x64
```
This copies all required Qt6 DLLs and plugins to the Release folder.
Copy the published files, `commonwealth-server.json`, and the native GNS bridge when GNS is enabled to the target host.
### Running the Application
After deployment, simply:
Start:
```bash
# Double-click this file:
build/bin/Release/CommonwealthOnlineHost.exe
# Or run from command line:
cd build/bin/Release
CommonwealthOnlineHost.exe
./CommonwealthOnline.Server serve --config commonwealth-server.json
```
---
A systemd example is provided at `server/commonwealth-online.service.example`.
## Distribution
## Windows dedicated server
To distribute the application to other machines:
### Option 1: Copy Entire Folder (Easiest)
```
Release/
├── CommonwealthOnlineHost.exe
├── Qt6Core.dll
├── Qt6Gui.dll
├── Qt6Widgets.dll
├── Qt6Network.dll
├── Qt6Concurrent.dll
├── Qt6DBus.dll
├── Qt6Xml.dll
└── plugins/
├── platforms/
├── styles/
└── imageformats/
```bat
dotnet publish server\CommonwealthOnline.Server.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o server\publish\win-x64
```
Just zip and send this folder - everything needed is included.
Run:
### Option 2: Use Qt Deployment Tool (Advanced)
Qt provides `windeployqt.exe` for automatic deployment:
```bash
C:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe build/bin/Release/CommonwealthOnlineHost.exe
```bat
CommonwealthOnline.Server.exe serve --config commonwealth-server.json
```
---
## Qt Host package
## Requirements for Users
Run:
Recipients of the `.exe` need **only**:
- Windows 10 or later
- Python 3.9+ (for running the relay server)
- No Visual Studio or Qt installation needed
---
## Troubleshooting
### "Entry point not found" error
The DLLs weren't deployed. Run:
```bash
deploy.bat
```
### "The procedure entry point... could not be located"
Likely a missing or incompatible DLL. Try redeploying:
```bash
# Clean old DLLs
del build\bin\Release\*.dll
# Redeploy
deploy.bat
```
### Application window doesn't appear
The GUI may have launched but is hidden. Check:
1. Task Manager for `CommonwealthOnlineHost.exe` process
2. Try running from command line to see error messages
3. Ensure Python and server directory are accessible
---
## Development Build vs Release Build
**For Development:**
```bash
# Just build (DLLs not deployed)
build.bat
```
**For Distribution:**
```bash
# Build + deploy DLLs
```bat
build.bat
deploy.bat
```
---
CMake publishes the C# server self-contained and stages it under:
## Next Steps
1. **Test Locally**
- Run `CommonwealthOnlineHost.exe`
- Click "Start Server"
- Run `fake_client.py` from server folder
- Verify client appears in table
2. **Package for Release**
- Run `deploy.bat`
- Zip `build/bin/Release/` folder
- Distribute to users
3. **Create Installer** (Future Enhancement)
- Use NSIS or WiX to create `.msi` installer
- Automatically handles DLL deployment
- Adds Start Menu shortcuts
- Enables uninstall
---
## File Locations
```
host-gui/
├── CMakeLists.txt # Build configuration
├── build.bat # Compile application
├── deploy.bat # Deploy Qt DLLs ← Run this after build.bat
├── check-setup.bat # Verify prerequisites
├── find-qt.bat # Locate Qt6 installation
├── README.md # User guide
├── SETUP.md # Setup instructions
├── src/ # Source code
└── build/
└── bin/
└── Release/
├── CommonwealthOnlineHost.exe
├── Qt6*.dll # Runtime libraries
└── plugins/ # Qt plugins
```text
build\bin\Release\server\
```
---
The deploy script verifies that `CommonwealthOnline.Server.exe` exists and rejects staged Python source.
## Creating a Portable Distribution
Package the entire `build\bin\Release` directory so the Qt runtime, plugins and bundled server remain together.
### Windows Host GUI (unchanged)
## Runtime state
To create a self-contained package anyone can run:
Preserve these files across upgrades:
```bash
# Build the application from the repository root
build.bat
- `commonwealth-server.json`
- `bans.json`
- `.admin-token`
# Deploy DLLs
deploy.bat
The CMake staging step preserves those files when replacing the server binaries.
# Create distribution package
# (build.bat / deploy.bat already stage server\ next to the exe)
mkdir Commonwealth-Online-Host
xcopy /I /E build\bin\Release Commonwealth-Online-Host\
copy README.md Commonwealth-Online-Host\README.txt
## Network
# Zip and distribute
# Send Commonwealth-Online-Host.zip to users
```
Default ports:
Users extract and run `CommonwealthOnlineHost.exe` - no setup needed!
Target machines still need Python 3.9+ on PATH for the bundled relay server.
- TCP 7777: gameplay compatibility
- UDP 7777: GameNetworkingSockets when enabled
- UDP 7778: LAN discovery
- TCP 127.0.0.1:7779: authenticated admin control
### Linux dedicated server packaging
Do not expose the admin port to the network.
Prefer a `.tar.gz` archive of the `server/` directory so the executable bit on `start.sh` is retained.
## GNS bridge
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.
---
## Support
If users encounter issues:
1. Ensure `deployment completed successfully` (see output)
2. Verify Python is installed and in PATH
3. Check that `server\` exists next to the executable (contains `consumer_server_cli.py`)
4. Run from command line to see detailed error messages
---
## Performance Notes
- Startup time: <1 second
- Memory usage: ~80-150 MB
- CPU: Minimal (event-driven)
- DLL size: ~100-150 MB total (but only loaded once)
Deploy once, run forever! 🚀
The native bridge is platform-specific. Set `gns_bridge_path` when it is not installed beside the server. GNS startup is fail-loud when enabled and the bridge cannot be loaded.
+31 -99
View File
@@ -1,118 +1,50 @@
# Qt GUI Host Development
# Development
This directory contains the native C++ Qt6 GUI application for hosting Commonwealth Online servers.
## Server
## Quick Start for Developers
The dedicated server is a C#/.NET project at `server/CommonwealthOnline.Server.csproj`.
### Prerequisites
Core files:
1. **Qt6.4+**
- Download from https://www.qt.io/download-open-source
- Install to default location (C:\Qt\6.4.0) or adjust `build.bat`
- `AuthoritativeServer.cs`: sessions, authoritative gameplay state, security, interest filtering
- `ProtocolCore.cs`: JSON codec, transport policy, snapshot sequencing/envelope
- `ProtocolValidation.cs`: Protocol V2 packet validation
- `Domain.cs`: sessions, bans, NPC authority, world presets
- `TcpTransport.cs`: newline-framed TCP compatibility transport
- `GnsTransport.cs`: C# wrapper over the native GameNetworkingSockets bridge
- `AdminDiscovery.cs`: authenticated localhost admin channel and LAN discovery
- `Program.cs`: CLI, interactive server, management commands and synthetic load command
2. **Visual Studio 2022**
- Install C++ development tools
- Required for MSVC compiler
3. **CMake 3.20+**
- Download from https://cmake.org/download
4. **Python 3.9+**
- Required for running the relay server subprocess
- Add to PATH
### Building
Build and test:
```bash
cd host-gui
build.bat
cd server
dotnet build CommonwealthOnline.Server.csproj -c Release
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
```
Or manually:
The test harness uses in-memory connections for authoritative behavior and contains no external test-framework package dependency.
```bash
cd host-gui
mkdir build
cd build
cmake .. -G "Visual Studio 17 2022"
cmake --build . --config Release
```
## Native GNS bridge
### Running
`server/native_transport` remains C++. Keep it transport-only. Do not move validation, identity, interest filtering, NPC authority, combat, world state or caching into the bridge.
```bash
.\build\bin\Release\CommonwealthOnlineHost.exe
```
The C ABI is the boundary consumed from `GnsTransport.cs`.
## Project Structure
## Qt Host
- `CMakeLists.txt` - Qt6 build configuration
- `src/main.cpp` - Application entry point
- `src/MainWindow.h/cpp` - Main UI window
- `src/ServerProcess.h/cpp` - Subprocess manager for relay
- `src/resources/` - Icons and resources
- `build.bat` - Windows build script
- `README.md` - User documentation
The Qt host remains C++. `ServerProcess` launches the published C# server and communicates through the authenticated localhost admin channel. It must not invoke a scripting runtime.
## Architecture
CMake publishes a self-contained server as part of the Host GUI post-build step.
### MainWindow
- Handles all UI elements (buttons, tables, labels, text areas)
- Manages server start/stop through ServerProcess
- Updates stats and client list in real-time
- Displays logs with timestamps
## Protocol changes
### ServerProcess
- Spawns Python relay server as subprocess
- Captures stdout/stderr in real-time
- Parses log output
- Handles process lifecycle (start, stop, errors)
Protocol behavior is intentionally independent from transport. If adding a packet type:
### Communication
- Uses `QProcess` for subprocess management
- Parses CLI output for stats and client data
- Emits Qt signals for UI updates
1. Define validation and normalization in C#.
2. Decide its delivery policy in `TransportPolicy`.
3. Default new control/state traffic to reliable/ordered.
4. Use unreliable/sequenced only for latest-wins snapshot families.
5. Add tests before changing the client.
## Design Decisions
1. **Subprocess Architecture**: Server runs in separate process so GUI can restart/crash without affecting active connections
2. **Python Relay**: Uses existing Python CLI to avoid duplicating networking logic in C++
3. **Live Logs**: Captures and displays all server output for debugging and transparency
4. **Minimal Dependencies**: Qt6 core only, no additional frameworks or heavy dependencies
5. **Dark Theme**: Fallout 4-inspired styling with amber/green accents matches game aesthetic
## Performance
- **Startup**: <1 second (just Qt6 initialization)
- **Memory**: ~50-100 MB baseline
- **Executable Size**: ~15-20 MB (with Qt6 DLLs included)
- **CPU**: Minimal, only updates on events
## Next Steps
- [ ] Implement stats fetching via CLI JSON output
- [ ] Add admin command buttons (set time/weather)
- [ ] Config file editor panel
- [ ] System tray icon
- [ ] Settings panel
- [ ] Player kick/ban interface
- [ ] Logging export functionality
- [ ] Performance profiling and optimization
## Troubleshooting
### CMake can't find Qt6
Ensure Qt6 path is set in `build.bat` or CMake cache.
### Build fails with MSVC errors
Check that Visual Studio 2022 with C++ tools is installed.
### Python not found at runtime
Ensure Python 3.9+ is installed and in PATH. Restart the GUI or set `PYTHON` environment variable.
### Application window appears but doesn't respond
Check console output or run from command line to see error messages.
Never cache/replay discrete action events, accept stale NPC authority epochs, accept stale snapshot sequences, or bypass server-owned identity and interest validation.
+35 -158
View File
@@ -1,178 +1,55 @@
# Commonwealth Online - Qt GUI Host
# Commonwealth Online Server and Qt Host
Production-ready Qt6 GUI application for hosting Commonwealth Online servers on Windows.
This repository contains the Commonwealth Online authoritative dedicated server and the Qt Host GUI.
**Looking for the CLI only?** You do not need to build this repo. Download the [`server/`](server/) folder and follow [server/README.md](server/README.md) — run `start.bat` (Windows) or `./start.sh` (Linux / macOS). Python 3.9+ is all you need.
The dedicated server is C#/.NET. There is no Python server runtime or Python test suite. Valve GameNetworkingSockets remains behind the small native C++ bridge in `server/native_transport/`.
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.
## Server
```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
dotnet build CommonwealthOnline.Server.csproj -c Release
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --interactive
```
Important notes:
The server owns Protocol V2 admission, server-owned player IDs, packet validation, movement validation, interest filtering, durable player state, scoped NPC authority and epochs, combat routing, world state, bans, rate limits, LAN discovery and localhost administration.
- `./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
Transport policy remains:
## Quick Start (GUI)
- `transform`, `npcState`: unreliable/sequenced under GNS
- session/control, player state, combat, world state and authority: reliable/ordered
**First time? Follow the [Setup Instructions](SETUP.md)**
TCP compatibility keeps newline framing inside the TCP transport only. GNS is message-oriented and uses the `COG2` snapshot sequence envelope for latest-wins snapshots.
### Building
## Native GNS bridge
```bash
# Check prerequisites
`server/native_transport` stays C++ and owns only GNS listen/connection/message mechanics and endpoint lookup. C# loads the existing C ABI directly.
## Qt Host GUI
The Qt Host remains native C++. It launches the published `CommonwealthOnline.Server` process and uses the authenticated localhost admin channel for stats, clients, kicks and bans.
Build requirements on Windows:
- Visual Studio 2022 C++ tools
- CMake 3.20+
- Qt 6.4+
- .NET 8 SDK
```bat
check-setup.bat
# Build
build.bat
deploy.bat
```
Output: `build\bin\Release\CommonwealthOnlineHost.exe`
CMake publishes the C# server self-contained and stages it under the Host GUI `server` directory. Packaged users do not need Python or a separate .NET runtime.
## Features
## Default ports
- **Start/Stop Server**: Compact toolbar controls with a live status indicator
- **Server Settings**: Edit name, description, bind address, port, max players, and log level from Settings
- **Status Strip**: Bind address, LAN, clients, uptime, and packet counters at a glance
- **Client List**: Live table of connected clients and connection details
- **Server Logs**: Resizable log pane with timestamps
- **Utility UI**: Light admin-tool layout (toolbar, status strip, splitter panes)
- **Auto-detection**: Automatically finds Python and server directory
- **Subprocess Management**: Server runs in separate process; GUI crash doesn't kill server
- TCP 7777: gameplay compatibility
- UDP 7777: GNS gameplay when enabled
- UDP 7778: LAN discovery
- TCP 127.0.0.1:7779: authenticated admin control
## Requirements
- Windows 10 or later
- **Qt 6.4+** (install from https://www.qt.io/download-open-source)
- **Visual Studio 2022** with C++ tools
- **CMake 3.20+**
- **Python 3.9+** (for running the relay server)
## Setup Issues?
See [SETUP.md](SETUP.md) for detailed instructions and troubleshooting.
## Building
### Quick Build
```bash
check-setup.bat # Verify prerequisites
build.bat # Auto-detect Qt6 and build
```
### From Visual Studio
1. Open this repository folder in Visual Studio 2022
2. Wait for CMake to auto-configure
3. Build → Build All
4. Run the executable
### From Command Line (Manual)
```bash
mkdir build
cd build
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\Qt\6.8.0\msvc2022_64"
cmake --build . --config Release
```
## First Run
1. Double-click `CommonwealthOnlineHost.exe`
2. Click **Start**
3. Server will bind to 0.0.0.0:7777 (configurable in commonwealth-server.json)
4. View real-time logs and connected clients
## Configuration
Use **Settings** in the Host GUI to edit settings, or edit `commonwealth-server.json` in the server folder:
- `host`: Bind address
- `port`: Server port
- `server_name`: Display name (shown in LAN browser)
- `server_description`: Short description (optional)
- `max_players`: Max player count
- `log_verbosity`: debug/info/warning/error
Saved changes apply the next time the server is started. If the server is already running, stop and start it again.
## Layout
```
├── CMakeLists.txt # Build configuration
├── build.bat # Auto-build script
├── check-setup.bat # Verify prerequisites
├── SETUP.md # Setup instructions
├── README.md # This file
├── DEVELOPMENT.md # Developer guide
├── IMPLEMENTATION.md # Architecture docs
├── src/
│ ├── main.cpp # Application entry point
│ ├── MainWindow.h/cpp # Main window UI and logic
│ ├── ConfigDialog.h/cpp # Server settings editor dialog
│ ├── ServerProcess.h/cpp # Subprocess manager for relay server
│ └── resources/
│ ├── resources.qrc # Qt resource manifest
│ └── icons/
│ └── app.ico # Application icon
├── server/ # Python relay server (CLI; no build needed)
└── build/ # Build output directory (after building)
└── bin/
└── Release/
└── CommonwealthOnlineHost.exe
```
## Architecture
The GUI spawns the Python relay server (`consumer_server_cli.py`) as a subprocess and:
- Captures stdout/stderr for real-time logs
- Parses log output to extract stats and client info
- Provides UI for server control and monitoring
- Maintains server state even if GUI crashes
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for detailed architecture.
## Troubleshooting
**Qt6 not found?**
- Install Qt6 from https://www.qt.io/download-open-source
- Use default path: C:\Qt\6.8.0\msvc2022_64
- Run `check-setup.bat` to verify
**Visual Studio not found?**
- Install Visual Studio 2022 with C++ tools
- See [SETUP.md](SETUP.md)
**CMake error?**
- Install CMake from https://cmake.org/download/
- Select "Add CMake to PATH" during install
**Build fails?**
- See [SETUP.md](SETUP.md) for manual build instructions
- Review [DEVELOPMENT.md](DEVELOPMENT.md) for developer notes
## Future Enhancements
- [x] Player kick/ban buttons (via localhost admin port)
- [x] Config file editor in GUI (Settings dialog)
- [ ] Admin commands (set time/weather directly from GUI)
- [ ] Ban reason dialog in GUI
- [ ] System tray icon with quick access
- [ ] Server history and logs export
- [ ] Installer (.msi or .exe wrapper)
See [server/README.md](server/README.md), [SETUP.md](SETUP.md), [DEVELOPMENT.md](DEVELOPMENT.md), and [DEPLOYMENT.md](DEPLOYMENT.md).
+33 -214
View File
@@ -1,231 +1,50 @@
# Commonwealth Online Qt GUI - Setup Instructions
# Setup
## Quick Setup (3 Steps)
## Dedicated server development
### Step 1: Install Qt6
1. Download Qt6 from: **https://www.qt.io/download-open-source**
2. Run the installer and select:
- ✓ Qt 6.8 (or latest 6.x)
- ✓ MSVC 2022 64-bit component
- Install to default location: `C:\Qt\6.8.0\`
3. After installation, verify the path exists:
```
C:\Qt\6.8.0\msvc2022_64\lib\cmake\Qt6
```
### Step 2: Verify Prerequisites
Run the setup checker:
Install the .NET 8 SDK.
```bash
cd host-gui
check-setup.bat
cd server
dotnet build CommonwealthOnline.Server.csproj -c Release
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
```
This verifies:
- ✓ CMake 3.20+
- ✓ Visual Studio 2022
- ✓ Qt6 installation
### Step 3: Build
Run from source:
```bash
cd host-gui
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --interactive
```
## Windows Qt Host development
Required:
- Visual Studio 2022 with C++ tools
- CMake 3.20+
- Qt 6.4+ MSVC 2022 package
- .NET 8 SDK
Run:
```bat
check-setup.bat
build.bat
```
The script will:
1. Auto-detect Qt6 location
2. Configure CMake
3. Build Release executable
4. Output: `host-gui/build/bin/Release/CommonwealthOnlineHost.exe`
CMake builds the Qt host and publishes the bundled C# server as a self-contained Windows executable under the staged `server` directory beside the host.
---
## GameNetworkingSockets
## Detailed Setup (If Quick Setup Fails)
The authoritative server is C#. Valve GameNetworkingSockets remains in the native C++ bridge under `server/native_transport`.
### Manual Qt6 Installation
A server with `enable_gns_transport: true` needs the appropriate bridge library beside the server or a valid `gns_bridge_path`.
1. **Download Qt Online Installer**
- Go to https://www.qt.io/download-open-source
- Download "Qt Online Installer for Windows"
## Ports
2. **Run Installer**
- Create Qt account (free)
- Select "Custom installation"
- Under "Qt 6.8.0" (or latest):
- ✓ MSVC 2022 64-bit
- ✓ Qt 5compat (optional)
- Under "Developer and Designer Tools":
- ✓ CMake (if not already installed)
- Click "Install"
- Gameplay TCP compatibility: TCP 7777 by default
- GNS gameplay: UDP 7777 by default when enabled
- LAN discovery: UDP 7778
- Admin control: TCP 127.0.0.1:7779 by default
3. **Verify Installation**
- Check that this folder exists:
```
C:\Qt\6.8.0\msvc2022_64\
```
- Should contain:
```
lib\cmake\Qt6\
bin\
include\
plugins\
```
### Manual CMake Installation
If CMake isn't found:
1. Download from: https://cmake.org/download/
2. Run installer
3. When prompted, select "Add CMake to PATH"
4. Restart your terminal/command prompt
### Manual Visual Studio 2022 Installation
If Visual Studio 2022 isn't found:
1. Download from: https://visualstudio.microsoft.com/downloads/
2. Run installer
3. Select "Desktop development with C++"
4. Install
---
## Building Manually (If Scripts Fail)
### Command Line Build
```bash
cd host-gui
mkdir build
cd build
# Configure (replace path with your Qt6 location)
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\Qt\6.8.0\msvc2022_64"
# Build Release
cmake --build . --config Release
```
### Visual Studio IDE Build
1. Open Visual Studio 2022
2. File → Open → Folder
3. Select `host-gui` folder
4. Wait for CMake configuration
5. Build → Build All
6. Executable at: `host-gui/build/bin/Release/CommonwealthOnlineHost.exe`
---
## Troubleshooting
### "Qt6 not found"
**Solution 1**: Edit `build.bat` and update Qt6 search paths
- Find the line with `PATHS_TO_CHECK`
- Add your Qt6 installation path
**Solution 2**: Use manual CMake with explicit path
```bash
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\path\to\your\Qt6"
```
### "Visual Studio not found"
Make sure Visual Studio 2022 is installed with C++ development tools:
1. Open Visual Studio Installer
2. Modify your installation
3. Select "Desktop development with C++"
4. Click "Modify"
### "CMake not found"
Add CMake to PATH:
1. Install CMake from cmake.org
2. Select "Add CMake to PATH"
3. Restart terminal
### "Build fails with link errors"
Ensure you're using the matching MSVC version:
- Qt6 installed for MSVC 2022 64-bit
- Building with "Visual Studio 17 2022" generator
- Using Release build configuration
---
## Running the Application
After successful build:
```bash
cd host-gui
start build\bin\Release\CommonwealthOnlineHost.exe
```
Or double-click:
```
host-gui\build\bin\Release\CommonwealthOnlineHost.exe
```
---
## Environment Setup (Advanced)
If you want to set up your environment permanently:
### Windows Environment Variables
1. Press `Win + X`, select "System"
2. Click "Advanced system settings"
3. Click "Environment Variables"
4. Add or update:
```
CMAKE_PREFIX_PATH = C:\Qt\6.8.0\msvc2022_64
```
5. Restart terminal
Then you can just run:
```bash
cd host-gui
mkdir build
cd build
cmake ..
cmake --build . --config Release
```
---
## Next Steps After Build
Once you have a successful build:
1. **Test the Application**
- Launch `CommonwealthOnlineHost.exe`
- Click "Start Server"
- Verify logs appear
2. **Connect Clients**
- Run fake_client from server directory
- Should see client in GUI table
3. **Customize**
- Edit colors/styling in `MainWindow.cpp`
- Add admin buttons in `MainWindow.h/cpp`
- Extend ServerProcess for more features
---
## Support
If you encounter issues:
1. Run `check-setup.bat` to verify prerequisites
2. Review output and error messages carefully
3. Check this troubleshooting guide
4. Try manual CMake command with explicit paths
The admin port must remain localhost-only.
+25 -101
View File
@@ -3,25 +3,20 @@ setlocal enabledelayedexpansion
echo.
echo ================================================================================
echo Commonwealth Online - Qt GUI Build Script (with Qt6 Auto-Detection)
echo Commonwealth Online - Qt Host and C# Server Build
echo ================================================================================
echo.
REM Check if CMake is installed
cmake --version >nul 2>&1
if errorlevel 1 (
cmake --version >nul 2>&1 || (
echo ERROR: CMake is not installed or not in PATH.
echo Please install CMake from https://cmake.org/download/
echo Then add it to your PATH and restart this script.
pause
exit /b 1
)
dotnet --version >nul 2>&1 || (
echo ERROR: .NET 8 SDK is not installed or dotnet is not in PATH.
exit /b 1
)
REM Auto-detect Qt6 installation
echo Searching for Qt6 installation...
set QT6_PATH=
REM Common Qt6 installation paths (check in order)
set PATHS_TO_CHECK[0]=C:\Qt\6.11.1\msvc2022_64
set PATHS_TO_CHECK[1]=C:\Qt\6.10.2\msvc2022_64
set PATHS_TO_CHECK[2]=C:\Qt\6.8.0\msvc2022_64
@@ -30,103 +25,32 @@ set PATHS_TO_CHECK[4]=C:\Qt\6.6.0\msvc2022_64
set PATHS_TO_CHECK[5]=C:\Qt\6.5.0\msvc2022_64
set PATHS_TO_CHECK[6]=C:\Qt\6.4.0\msvc2022_64
for /l %%i in (0,1,7) do (
if exist "!PATHS_TO_CHECK[%%i]!\lib\cmake\Qt6" (
set QT6_PATH=!PATHS_TO_CHECK[%%i]!
echo Found Qt6 at: !QT6_PATH!
goto found_qt6
)
for /l %%i in (0,1,6) do (
if exist "!PATHS_TO_CHECK[%%i]!\lib\cmake\Qt6" if "!QT6_PATH!"=="" set QT6_PATH=!PATHS_TO_CHECK[%%i]!
)
:found_qt6
if "!QT6_PATH!"=="" (
echo.
echo ERROR: Qt6 not found at common installation paths.
echo.
echo Please install Qt6 from https://www.qt.io/download-open-source
echo.
echo Common installation paths:
echo - C:\Qt\6.8.0\msvc2022_64
echo - C:\Qt\6.7.0\msvc2022_64
echo - C:\Qt\6.6.0\msvc2022_64
echo.
echo Alternatively, manually set Qt6 path by editing this script
echo or run CMake manually with:
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="C:\path\to\Qt6"
echo.
pause
echo ERROR: Qt6 not found at standard C:\Qt paths.
exit /b 1
)
REM Create build directory
if not exist "build" (
echo.
echo Creating build directory...
mkdir build
)
echo Qt6: !QT6_PATH!
echo .NET:
dotnet --version
cd build
dotnet build server\CommonwealthOnline.Server.csproj -c Release --nologo || exit /b 1
dotnet run --project server\tests\CommonwealthOnline.Server.Tests.csproj -c Release --no-restore || exit /b 1
echo.
echo Configuring project with CMake...
echo CMake: %CMAKE_PREFIX_PATH%
echo Qt6: !QT6_PATH!
echo VS: Visual Studio 17 2022
echo.
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="!QT6_PATH!"
if errorlevel 1 (
echo.
echo ERROR: CMake configuration failed.
echo.
echo Troubleshooting:
echo 1. Verify Qt6 is installed at: !QT6_PATH!
echo 2. Verify Visual Studio 2022 is installed with C++ tools
echo 3. Try running this script again
echo.
echo Manual configuration command:
echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="!QT6_PATH!"
echo.
pause
exit /b 1
)
echo.
echo Configuration successful!
echo.
echo Building project (this may take a few minutes)...
echo.
cmake --build . --config Release
if errorlevel 1 (
echo.
echo ERROR: Build failed.
echo.
echo Troubleshooting:
echo 1. Check that Visual Studio 2022 is installed
echo 2. Make sure C++ development tools are installed
echo 3. Try building again
echo.
pause
exit /b 1
)
if not exist build mkdir build
pushd build
cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="!QT6_PATH!" || (popd & exit /b 1)
cmake --build . --config Release || (popd & exit /b 1)
popd
echo.
echo ================================================================================
echo Build successful!
echo ================================================================================
echo.
echo Executable created at:
echo %cd%\bin\Release\CommonwealthOnlineHost.exe
echo.
echo The Python server\ folder is copied next to the exe by CMake post-build.
echo.
echo To run the application:
echo cd ..
echo start build\bin\Release\CommonwealthOnlineHost.exe
echo.
echo For a portable package, also run deploy.bat afterwards.
echo.
pause
echo Build successful
echo ================================================================================
echo Host GUI: build\bin\Release\CommonwealthOnlineHost.exe
echo CMake also published and staged the self-contained C# server beside the host.
echo Python is not required.
exit /b 0
+34 -44
View File
@@ -3,64 +3,54 @@ setlocal enabledelayedexpansion
echo.
echo ================================================================================
echo Commonwealth Online - Qt GUI Setup Helper
echo Commonwealth Online - Qt Host and C# Server Setup Check
echo ================================================================================
echo.
echo This script will help you set up Qt6 for building the GUI.
echo.
REM Check CMake
cmake --version >nul 2>&1
if errorlevel 1 (
echo [ERROR] CMake not found. Please install from https://cmake.org/download
pause
echo [ERROR] CMake not found.
exit /b 1
)
echo [OK] CMake found
REM Check Visual Studio
if not exist "C:\Program Files\Microsoft Visual Studio\2022\Community" (
if not exist "C:\Program Files\Microsoft Visual Studio\2022\Professional" (
echo.
echo [ERROR] Visual Studio 2022 not found
echo Please install from https://visualstudio.microsoft.com/downloads/
pause
exit /b 1
)
dotnet --version >nul 2>&1
if errorlevel 1 (
echo [ERROR] .NET 8 SDK not found or dotnet is not in PATH.
exit /b 1
)
for /f "tokens=*" %%V in ('dotnet --version') do set DOTNET_VERSION=%%V
echo [OK] .NET SDK found: !DOTNET_VERSION!
if not exist "C:\Program Files\Microsoft Visual Studio\2022\Community" if not exist "C:\Program Files\Microsoft Visual Studio\2022\Professional" if not exist "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" (
echo [ERROR] Visual Studio 2022 not found.
exit /b 1
)
echo [OK] Visual Studio 2022 found
REM Check Qt6
set QT6_FOUND=0
if exist "C:\Qt\6.8.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.8.0\msvc2022_64
if exist "C:\Qt\6.7.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.7.0\msvc2022_64
if exist "C:\Qt\6.6.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.6.0\msvc2022_64
if exist "C:\Qt\6.5.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.5.0\msvc2022_64
if exist "C:\Qt\6.4.0\msvc2022_64\lib\cmake\Qt6" set QT6_FOUND=1 & set QT_PATH=C:\Qt\6.4.0\msvc2022_64
if %QT6_FOUND%==0 (
echo.
echo [ERROR] Qt6 not found at standard locations
echo.
echo Qt6 installation required! Download from: https://www.qt.io/download-open-source
echo.
echo Installation paths checked:
echo - C:\Qt\6.8.0\msvc2022_64
echo - C:\Qt\6.7.0\msvc2022_64
echo - C:\Qt\6.6.0\msvc2022_64
echo - C:\Qt\6.5.0\msvc2022_64
echo - C:\Qt\6.4.0\msvc2022_64
echo.
echo If Qt6 is installed elsewhere, manually edit build.bat
echo and update the Qt6 search paths.
echo.
pause
exit /b 1
set QT_PATH=
for %%Q in (
"C:\Qt\6.11.1\msvc2022_64"
"C:\Qt\6.10.2\msvc2022_64"
"C:\Qt\6.8.0\msvc2022_64"
"C:\Qt\6.7.0\msvc2022_64"
"C:\Qt\6.6.0\msvc2022_64"
"C:\Qt\6.5.0\msvc2022_64"
"C:\Qt\6.4.0\msvc2022_64"
) do (
if exist "%%~Q\lib\cmake\Qt6" if !QT6_FOUND!==0 (
set QT6_FOUND=1
set QT_PATH=%%~Q
)
)
if %QT6_FOUND%==0 (
echo [ERROR] Qt6 MSVC 2022 package not found at the standard C:\Qt locations.
exit /b 1
)
echo [OK] Qt6 found at: %QT_PATH%
echo.
echo All prerequisites found! You can now run build.bat
echo.
pause
echo All prerequisites found. build.bat will build the Qt host and publish the bundled C# server.
exit /b 0
+9 -33
View File
@@ -1,11 +1,9 @@
# Stage server/ next to the Host GUI while preserving local runtime state.
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}")
message(FATAL_ERROR "Server source directory not found: ${CO_SERVER_SOURCE_DIR}")
if(NOT EXISTS "${CO_SERVER_PUBLISH_DIR}")
message(FATAL_ERROR "Published C# server directory not found: ${CO_SERVER_PUBLISH_DIR}")
endif()
if(NOT EXISTS "${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
message(FATAL_ERROR
"Server entrypoint not found: ${CO_SERVER_SOURCE_DIR}/consumer_server_cli.py")
if(NOT EXISTS "${CO_SERVER_PUBLISH_DIR}/CommonwealthOnline.Server${CO_SERVER_EXECUTABLE_SUFFIX}")
message(FATAL_ERROR "Published C# server entrypoint not found: ${CO_SERVER_PUBLISH_DIR}/CommonwealthOnline.Server${CO_SERVER_EXECUTABLE_SUFFIX}")
endif()
set(_preserve_config "")
@@ -27,33 +25,12 @@ endif()
file(REMOVE_RECURSE "${CO_SERVER_STAGE_DIR}")
file(MAKE_DIRECTORY "${CO_SERVER_STAGE_DIR}")
# Prefer a filtered copy so caches and local runtime state 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"
OR _entry STREQUAL ".venv"
OR _entry STREQUAL "logs"
OR _entry STREQUAL "tests"
OR _entry STREQUAL "bans.json"
OR _entry STREQUAL ".admin-token")
continue()
endif()
file(COPY "${CO_SERVER_SOURCE_DIR}/${_entry}"
DESTINATION "${CO_SERVER_STAGE_DIR}"
PATTERN "__pycache__" EXCLUDE
PATTERN "*.pyc" EXCLUDE
PATTERN ".pytest_cache" EXCLUDE
PATTERN ".venv" EXCLUDE
PATTERN "logs" EXCLUDE
PATTERN "bans.json" EXCLUDE
PATTERN ".admin-token" EXCLUDE
)
endforeach()
file(COPY "${CO_SERVER_PUBLISH_DIR}/" DESTINATION "${CO_SERVER_STAGE_DIR}")
if(NOT "${_preserve_config}" STREQUAL "")
file(WRITE "${CO_SERVER_STAGE_CONFIG}" "${_preserve_config}")
elseif(EXISTS "${CO_SERVER_SOURCE_DIR}/commonwealth-server.json")
file(COPY "${CO_SERVER_SOURCE_DIR}/commonwealth-server.json" DESTINATION "${CO_SERVER_STAGE_DIR}")
endif()
if(NOT "${_preserve_bans}" STREQUAL "")
@@ -64,7 +41,6 @@ if(NOT "${_preserve_admin_token}" STREQUAL "")
file(WRITE "${_stage_admin_token}" "${_preserve_admin_token}")
endif()
if(NOT EXISTS "${CO_SERVER_STAGE_DIR}/consumer_server_cli.py")
message(FATAL_ERROR
"Failed to stage server entrypoint to ${CO_SERVER_STAGE_DIR}/consumer_server_cli.py")
if(NOT EXISTS "${CO_SERVER_STAGE_DIR}/CommonwealthOnline.Server${CO_SERVER_EXECUTABLE_SUFFIX}")
message(FATAL_ERROR "Failed to stage C# server entrypoint")
endif()
+27 -79
View File
@@ -3,103 +3,51 @@ setlocal enabledelayedexpansion
echo.
echo ================================================================================
echo Commonwealth Online GUI - Qt6 DLL Deployment
echo Commonwealth Online - Qt Host Deployment
echo ================================================================================
echo.
REM Find Qt6 installation (prefer the newest match; do not overwrite).
set QT6_PATH=
if exist "C:\Qt\6.11.1\msvc2022_64\bin" (
set QT6_PATH=C:\Qt\6.11.1\msvc2022_64
) else if exist "C:\Qt\6.10.2\msvc2022_64\bin" (
set QT6_PATH=C:\Qt\6.10.2\msvc2022_64
) else if exist "C:\Qt\6.8.0\msvc2022_64\bin" (
set QT6_PATH=C:\Qt\6.8.0\msvc2022_64
)
if exist "C:\Qt\6.11.1\msvc2022_64\bin" set QT6_PATH=C:\Qt\6.11.1\msvc2022_64
if "!QT6_PATH!"=="" if exist "C:\Qt\6.10.2\msvc2022_64\bin" set QT6_PATH=C:\Qt\6.10.2\msvc2022_64
if "!QT6_PATH!"=="" if exist "C:\Qt\6.8.0\msvc2022_64\bin" set QT6_PATH=C:\Qt\6.8.0\msvc2022_64
if "!QT6_PATH!"=="" (
echo ERROR: Could not find Qt6 installation.
echo Please ensure Qt6 is installed to one of these paths:
echo - C:\Qt\6.11.1\msvc2022_64
echo - C:\Qt\6.10.2\msvc2022_64
echo - C:\Qt\6.8.0\msvc2022_64
pause
exit /b 1
)
echo Found Qt6 at: !QT6_PATH!
echo.
echo Deploying Qt6 DLLs to release directory...
echo.
set DEPLOY_DIR=%cd%\build\bin\Release
if not exist "!DEPLOY_DIR!\CommonwealthOnlineHost.exe" (
echo ERROR: Host executable not found. Run build.bat first.
exit /b 1
)
REM Required DLLs
set DLLs=^
Qt6Core.dll ^
Qt6Gui.dll ^
Qt6Widgets.dll ^
Qt6Network.dll ^
Qt6Concurrent.dll ^
Qt6DBus.dll ^
Qt6Xml.dll
REM Copy DLLs
for %%D in (%DLLs%) do (
if exist "!QT6_PATH!\bin\%%D" (
echo Copying %%D...
copy /Y "!QT6_PATH!\bin\%%D" "!DEPLOY_DIR!\%%D" >nul
) else (
echo WARNING: %%D not found (may not be required)
)
set DLLS=Qt6Core.dll Qt6Gui.dll Qt6Widgets.dll Qt6Network.dll Qt6Concurrent.dll Qt6DBus.dll Qt6Xml.dll
for %%D in (%DLLS%) do (
if exist "!QT6_PATH!\bin\%%D" copy /Y "!QT6_PATH!\bin\%%D" "!DEPLOY_DIR!\%%D" >nul
)
REM Copy plugins directory
if not exist "!DEPLOY_DIR!\plugins" mkdir "!DEPLOY_DIR!\plugins"
xcopy /Y /Q /I "!QT6_PATH!\plugins\platforms" "!DEPLOY_DIR!\plugins\platforms\" >nul
xcopy /Y /Q /I "!QT6_PATH!\plugins\styles" "!DEPLOY_DIR!\plugins\styles\" >nul 2>&1
xcopy /Y /Q /I "!QT6_PATH!\plugins\imageformats" "!DEPLOY_DIR!\plugins\imageformats\" >nul 2>&1
echo Copying Qt plugins...
xcopy /Y /Q "!QT6_PATH!\plugins\platforms" "!DEPLOY_DIR!\plugins\platforms\"
xcopy /Y /Q "!QT6_PATH!\plugins\styles" "!DEPLOY_DIR!\plugins\styles\" 2>nul
xcopy /Y /Q "!QT6_PATH!\plugins\imageformats" "!DEPLOY_DIR!\plugins\imageformats\" 2>nul
set SERVER_DIR=!DEPLOY_DIR!\server
if not exist "!SERVER_DIR!\CommonwealthOnline.Server.exe" (
echo ERROR: Self-contained C# server is not staged beside the Host GUI.
echo Run build.bat. CMake publishes the server during the Host GUI build.
exit /b 1
)
REM Stage Python relay server next to the executable (bundled from repo server\)
set SERVER_SRC=%cd%\server
set SERVER_DST=!DEPLOY_DIR!\server
set SERVER_CFG=!SERVER_DST!\commonwealth-server.json
set SERVER_CFG_BAK=%TEMP%\co-host-server-config.json
if exist "!SERVER_SRC!\consumer_server_cli.py" (
echo Copying Python server from "!SERVER_SRC!"...
if exist "!SERVER_CFG!" copy /Y "!SERVER_CFG!" "!SERVER_CFG_BAK!" >nul
if not exist "!SERVER_DST!" mkdir "!SERVER_DST!"
robocopy "!SERVER_SRC!" "!SERVER_DST!" /E /XD __pycache__ .pytest_cache /XF *.pyc /NFL /NDL /NJH /NJS /nc /ns /np
if errorlevel 8 (
echo WARNING: Failed to copy server directory from "!SERVER_SRC!"
) else (
if exist "!SERVER_CFG_BAK!" (
copy /Y "!SERVER_CFG_BAK!" "!SERVER_CFG!" >nul
del /Q "!SERVER_CFG_BAK!" >nul 2>&1
echo Preserved existing commonwealth-server.json
)
echo Server directory staged at: !SERVER_DST!
)
) else (
echo WARNING: Server source not found at "!SERVER_SRC!"
echo Expected repo-local server\consumer_server_cli.py.
echo Rebuild with CMake post-build copy, or copy server\ manually.
if exist "!SERVER_DIR!\*.py" (
echo ERROR: Python source found in staged server package.
exit /b 1
)
echo.
echo ================================================================================
echo Deployment Complete!
echo ================================================================================
echo.
echo Executable is now ready to run:
echo Deployment complete:
echo !DEPLOY_DIR!\CommonwealthOnlineHost.exe
echo !SERVER_DIR!\CommonwealthOnline.Server.exe
echo.
echo You can now:
echo 1. Double-click CommonwealthOnlineHost.exe to run
echo 2. Or copy the entire Release folder to another machine
echo 3. Include all DLLs, plugins\, and server\ when distributing
echo.
pause
echo Copy the entire Release directory when distributing. Python and a separate .NET runtime are not required.
exit /b 0
+34 -43
View File
@@ -4,44 +4,40 @@
ERROR: "Only one usage of each socket address ... is normally permitted"
This means port 7777 is already in use by another process.
TCP port 7777 is already in use by another process.
QUICK FIXES:
============
QUICK FIX
=========
Option 1: Use fix-port.bat / fix-port.sh (Recommended)
------------------------------------------------------
Windows: double-click fix-port.bat
Linux / macOS: ./fix-port.sh
Windows:
fix-port.bat
Then choose an option:
- Press 1 to kill the blocking process and restart
- Press 2 to use a different port
- Press 3 to cancel
Linux / macOS:
./fix-port.sh
Option 2: Manually change the port
-----------------------------------
1. Open commonwealth-server.json in a text editor
2. Find the line: "port": 7777
3. Change to a different port, e.g. "port": 8000
4. Save the file
5. Run start.bat (Windows) or ./start.sh (Linux / macOS) again
Choose:
1. Kill the process using TCP 7777 and restart
2. Start on a different port
3. Cancel
Option 3: Use the CLI with port override
-----------------------------------------
Open a terminal in the server folder and run:
MANUAL PORT OVERRIDE
====================
# Windows
.venv\Scripts\python.exe -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
Published Windows server:
CommonwealthOnline.Server.exe serve --config commonwealth-server.json --port 8000
# Linux / macOS
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
Published Linux server:
./CommonwealthOnline.Server serve --config commonwealth-server.json --port 8000
If .venv does not exist yet, run start.bat / ./start.sh once first.
Source checkout:
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --port 8000
Option 4: Find and kill the blocking process manually
------------------------------------------------------
Windows (Command Prompt as Administrator):
You can also change "port" in commonwealth-server.json and restart.
FIND THE BLOCKING PROCESS
=========================
Windows, Administrator Command Prompt:
netstat -aon | find ":7777"
taskkill /PID <PID> /F
@@ -49,22 +45,17 @@ Linux / macOS:
lsof -i :7777
kill -9 <PID>
CHECKING DIFFERENT PORTS:
=========================
GNS NOTE
========
Common available ports:
- 8000
- 8080
- 9000
- 9999
When GameNetworkingSockets is enabled, the server uses the same numeric game
port over UDP. TCP and UDP are separate transports, but firewalls and router
rules must allow the protocol you intend to use.
Choose any port between 1024 and 65535 that isn't in use.
REMOTE HOSTING
==============
FORWARDING FOR REMOTE CONNECTIONS:
===================================
If using a non-standard port (not 7777), remember to:
1. Forward that port on your router
2. Tell remote players to connect to your_ip:port_number
If you change the game port, update firewall/router rules and tell players the
new host:port. Do not expose the localhost admin port (default TCP 7779).
================================================================================
+97 -199
View File
@@ -1,152 +1,42 @@
# Commonwealth Online Server - Quick Start
# Commonwealth Online Dedicated Server
Standalone dedicated relay server. No Qt/Host GUI build is required.
The dedicated server is implemented in C# on .NET 8. Python is not used by the server runtime, utilities, test suite, launch scripts, or Host GUI integration.
## Quick Launch
Valve GameNetworkingSockets remains in the native C++ bridge under `native_transport/`. The C# server loads its C ABI directly.
### Windows
## Run
Double-click `start.bat`, or run it from a terminal:
Packaged Windows:
```bat
start.bat
CommonwealthOnline.Server.exe serve --config commonwealth-server.json
```
### Linux / macOS
Packaged Linux:
```bash
chmod +x start.sh fix-port.sh # recovery step if the executable bit was lost
./start.sh
./CommonwealthOnline.Server serve --config commonwealth-server.json
```
The start script will:
1. Check that Python 3.9+ is installed
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:
```text
help
users
users once
ban 2 --reason griefing
world time 1430
quit
```
`users` live-updates the player table every second (press Enter to stop).
Update dependencies explicitly when needed:
Source checkout:
```bash
./start.sh --update-dependencies
dotnet build CommonwealthOnline.Server.csproj -c Release
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release
dotnet run --project CommonwealthOnline.Server.csproj -- serve --config commonwealth-server.json --interactive
```
`--update-dependencies` is consumed by the start script and is **not** forwarded to the server.
`start.bat` and `start.sh` prefer a published apphost, then a framework-dependent DLL, then `dotnet run` in a source checkout.
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).
## Config
---
## 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
Generate defaults:
```bash
sudo apt install python3 python3-venv
chmod +x start.sh
./start.sh
dotnet run --project CommonwealthOnline.Server.csproj -- config init commonwealth-server.json
```
### 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:
Existing field names remain supported:
```json
{
@@ -156,89 +46,97 @@ On first run, a default `commonwealth-server.json` file is created in the server
"server_description": "",
"max_players": 16,
"log_verbosity": "info",
"admin_port": 7779
"admin_port": 7779,
"enable_gns_transport": false,
"gns_bridge_path": null
}
```
### Port Already in Use?
When GNS is enabled, `host` must be an explicit IPv4 bind address. TCP and GNS may use the same numeric game port because they use TCP and UDP separately.
If you get an address-already-in-use error, the game port is already taken.
## Ports
**Option 1: Kill the blocking process**
- TCP 7777 by default: gameplay compatibility
- UDP 7777 by default: GNS gameplay when enabled
- UDP 7778: LAN discovery
- TCP 127.0.0.1:7779 by default: authenticated administration
Run `fix-port.bat` (Windows) or `./fix-port.sh` (Linux / macOS).
**Option 2: Use a different port**
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**: 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`
- **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 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)
## Admin CLI
```bash
cd server
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-server.txt
# Generate config
.venv/bin/python -u consumer_server_cli.py config init my-config.json
# 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 / 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
.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
dotnet run --project CommonwealthOnline.Server.csproj -- status --config commonwealth-server.json
dotnet run --project CommonwealthOnline.Server.csproj -- clients --config commonwealth-server.json
dotnet run --project CommonwealthOnline.Server.csproj -- kick 2 --reason griefing --config commonwealth-server.json
dotnet run --project CommonwealthOnline.Server.csproj -- ban 2 --reason griefing --config commonwealth-server.json
dotnet run --project CommonwealthOnline.Server.csproj -- unban 192.0.2.5 --config commonwealth-server.json
dotnet run --project CommonwealthOnline.Server.csproj -- bans --config commonwealth-server.json
dotnet run --project CommonwealthOnline.Server.csproj -- world time 1430 --config commonwealth-server.json
dotnet run --project CommonwealthOnline.Server.csproj -- world weather 0002b52a --config commonwealth-server.json
```
Run `.venv/bin/python -u consumer_server_cli.py --help` to see all available commands.
The admin channel authenticates with `.admin-token` beside the config file and binds to localhost only.
### Troubleshooting: Port in Use
## Load test
The C# synthetic Protocol V2 client replaces the old scripted fake clients:
```bash
.venv/bin/python -u consumer_server_cli.py serve --config commonwealth-server.json --port 8000
dotnet run --project CommonwealthOnline.Server.csproj -- load-test --host 127.0.0.1 --port 7777 --clients 16
```
## Architecture
C# owns:
- Protocol V2 decoding/validation
- session admission and server-owned player IDs
- movement validation and corrections
- cell/worldspace interest filtering
- durable player state
- scoped NPC authority and epochs
- combat routing
- world state
- bans and rate limits
- handshake and idle timeouts
- TCP compatibility
- GNS sequencing/envelope handling
- LAN discovery
- admin control
C++ owns only the Valve GNS transport bridge:
- initialization/shutdown
- UDP listen socket
- GNS connection lifecycle
- message polling/sending
- disconnects
- remote endpoint lookup
## Protocol guarantees
- Maximum message size remains 64 KiB.
- `transform` and `npcState` are unreliable/sequenced under GNS.
- Reliable gameplay/control packets remain reliable/ordered.
- Snapshot sequences are wrap-safe; stale and duplicate snapshots are rejected.
- Server-owned identity is applied before mutation/relay.
- Movement validation runs before transform mutation/relay.
- Interest filtering remains authoritative.
- NPC authority is scoped by cell/worldspace and epoch.
- Durable player state is cached; action events are not replay-cached.
- Ban, connection throttle and packet-rate checks remain server-side.
## Publish
Linux self-contained:
```bash
dotnet publish CommonwealthOnline.Server.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -o publish/linux-x64
```
Windows self-contained:
```bat
dotnet publish CommonwealthOnline.Server.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish\win-x64
```
`commonwealth-online.service.example` is provided for Linux systemd deployment.
-278
View File
@@ -1,278 +0,0 @@
"""
Authenticated localhost admin control channel for a running Commonwealth Online server.
Binds to 127.0.0.1 and speaks newline-delimited JSON request/response messages.
The authentication token is generated locally and is never exposed by the game protocol.
"""
from __future__ import annotations
import hmac
import json
import os
import secrets
import socket
import threading
from collections.abc import Callable
from pathlib import Path
from typing import Any
DEFAULT_ADMIN_PORT = 7779
ADMIN_HOST = "127.0.0.1"
ACCEPT_TIMEOUT_SECONDS = 0.5
MAX_ADMIN_LINE_BYTES = 64 * 1024
DEFAULT_ADMIN_TOKEN_PATH = Path(__file__).resolve().parent / ".admin-token"
def load_or_create_admin_token(path: str | Path | None = None) -> str:
"""Load the local admin token, creating it with restrictive permissions when absent."""
token_path = Path(path) if path is not None else DEFAULT_ADMIN_TOKEN_PATH
token_path.parent.mkdir(parents=True, exist_ok=True)
try:
existing = token_path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
existing = ""
except OSError as error:
raise RuntimeError(f"Could not read admin token file {token_path}: {error}") from error
if existing:
return existing
token = secrets.token_urlsafe(32)
temp_path = token_path.with_name(f".{token_path.name}.{os.getpid()}.tmp")
try:
with open(temp_path, "x", encoding="utf-8", newline="\n") as handle:
handle.write(token)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
try:
os.chmod(temp_path, 0o600)
except OSError:
pass
os.replace(temp_path, token_path)
try:
os.chmod(token_path, 0o600)
except OSError:
pass
except FileExistsError:
try:
temp_path.unlink()
except OSError:
pass
return load_or_create_admin_token(token_path)
except OSError as error:
try:
temp_path.unlink()
except OSError:
pass
raise RuntimeError(f"Could not create admin token file {token_path}: {error}") from error
return token
def load_admin_token(path: str | Path | None = None) -> str:
token_path = Path(path) if path is not None else DEFAULT_ADMIN_TOKEN_PATH
try:
token = token_path.read_text(encoding="utf-8").strip()
except OSError as error:
raise RuntimeError(f"Could not read admin token file {token_path}: {error}") from error
if not token:
raise RuntimeError(f"Admin token file is empty: {token_path}")
return token
class AdminServer:
"""Authenticated JSON-lines admin TCP server bound to loopback only."""
def __init__(
self,
handler: Callable[[dict[str, Any]], dict[str, Any]],
port: int = DEFAULT_ADMIN_PORT,
log: Callable[[str], None] | None = None,
token_path: str | Path | None = None,
) -> None:
self._handler = handler
self.port = port
self._log = log or (lambda _message: None)
self._token_path = Path(token_path) if token_path is not None else DEFAULT_ADMIN_TOKEN_PATH
self._admin_token = load_or_create_admin_token(self._token_path)
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:
with self._lock:
if self._running:
return
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
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((ADMIN_HOST, self.port))
server_socket.listen()
server_socket.settimeout(ACCEPT_TIMEOUT_SECONDS)
except OSError as error:
server_socket.close()
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
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
self._thread.start()
self._log(f"Admin control listening on {ADMIN_HOST}:{self.port} (localhost, authenticated)")
def stop(self) -> None:
with self._lock:
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:
server_socket.close()
except OSError:
pass
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)
def is_running(self) -> bool:
with self._lock:
return self._running
def _accept_loop(self) -> None:
try:
while True:
with self._lock:
if not self._running:
break
server_socket = self._server_socket
if server_socket is None:
break
try:
connection, address = server_socket.accept()
except socket.timeout:
continue
except OSError:
break
with self._lock:
self._client_sockets.add(connection)
thread = threading.Thread(
target=self._handle_connection,
args=(connection, address),
daemon=True,
)
thread.start()
finally:
with self._lock:
self._running = False
def _handle_connection(self, connection: socket.socket, address: tuple[str, int]) -> None:
peer = f"{address[0]}:{address[1]}"
try:
with connection:
buffer = b""
while True:
chunk = connection.recv(4096)
if not chunk:
break
buffer += chunk
if len(buffer) > MAX_ADMIN_LINE_BYTES and b"\n" not in buffer:
self._log(f"Rejected oversized admin request from {peer}")
return
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
if len(line_bytes) > MAX_ADMIN_LINE_BYTES:
response = {"ok": False, "error": "Admin request is too large."}
else:
try:
line = line_bytes.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError:
response = {"ok": False, "error": "Admin request must be valid UTF-8."}
else:
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}")
finally:
with self._lock:
self._client_sockets.discard(connection)
def _dispatch_line(self, line: str) -> dict[str, Any]:
try:
request = json.loads(line)
except json.JSONDecodeError as error:
return {"ok": False, "error": f"Invalid JSON: {error}"}
if not isinstance(request, dict):
return {"ok": False, "error": "Admin request must be a JSON object."}
supplied_token = request.pop("adminToken", None)
if not isinstance(supplied_token, str) or not hmac.compare_digest(supplied_token, self._admin_token):
return {"ok": False, "error": "Unauthorized admin request."}
try:
response = self._handler(request)
except Exception as error:
return {"ok": False, "error": str(error)}
if not isinstance(response, dict):
return {"ok": False, "error": "Admin handler returned a non-object response."}
return response
def send_admin_command(
request: dict[str, Any],
*,
host: str = ADMIN_HOST,
port: int = DEFAULT_ADMIN_PORT,
timeout_seconds: float = 3.0,
token_path: str | Path | None = None,
) -> dict[str, Any]:
"""Send one authenticated admin command to a running server and return the JSON response."""
authenticated_request = dict(request)
authenticated_request["adminToken"] = load_admin_token(token_path)
encoded = json.dumps(authenticated_request, separators=(",", ":")).encode("utf-8") + b"\n"
with socket.create_connection((host, port), timeout=timeout_seconds) as connection:
connection.sendall(encoded)
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
if len(buffer) > MAX_ADMIN_LINE_BYTES:
raise ValueError("Admin response exceeded the maximum size.")
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
-186
View File
@@ -1,186 +0,0 @@
"""Persistent, fail-loud IP ban storage for Commonwealth Online servers."""
from __future__ import annotations
import ipaddress
import json
import os
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class BanEntry:
ip: str
reason: str = ""
banned_at: float = 0.0
def to_dict(self) -> dict[str, Any]:
return {"ip": self.ip, "reason": self.reason, "bannedAt": self.banned_at}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> BanEntry | None:
raw_ip = str(data.get("ip", "")).strip()
if not raw_ip:
return None
try:
normalized_ip = str(ipaddress.ip_address(raw_ip))
except ValueError:
return None
reason = str(data.get("reason", "") or "")[:1024]
banned_at = data.get("bannedAt", data.get("banned_at", 0.0))
try:
banned_at_f = float(banned_at) if banned_at is not None else 0.0
except (TypeError, ValueError):
banned_at_f = 0.0
return cls(ip=normalized_ip, reason=reason, banned_at=banned_at_f)
class BanStore:
"""Thread-safe IP ban list with atomic JSON persistence.
Existing ban files are treated as security state. If one exists but cannot be parsed,
startup fails instead of silently replacing the effective ban list with an empty one.
"""
def __init__(self, path: str | Path | None = None) -> None:
self._path = Path(path) if path is not None else None
self._lock = threading.RLock()
self._bans: dict[str, BanEntry] = {}
if self._path is not None:
self.load()
@property
def path(self) -> Path | None:
return self._path
def set_path(self, path: str | Path | None) -> None:
with self._lock:
self._path = Path(path) if path is not None else None
@staticmethod
def _normalize_ip(ip: str) -> str:
text = str(ip).strip()
if not text:
raise ValueError("IP address cannot be empty.")
try:
return str(ipaddress.ip_address(text))
except ValueError as error:
raise ValueError(f"Invalid IP address: {text!r}") from error
def load(self) -> None:
with self._lock:
if self._path is None or not self._path.exists():
self._bans = {}
return
try:
with self._path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise RuntimeError(f"Could not load ban file {self._path}: {error}") from error
if not isinstance(data, dict) or not isinstance(data.get("banned_ips", []), list):
raise RuntimeError(f"Ban file {self._path} has an invalid schema.")
bans: dict[str, BanEntry] = {}
invalid_entries = 0
for item in data.get("banned_ips", []):
if not isinstance(item, dict):
invalid_entries += 1
continue
entry = BanEntry.from_dict(item)
if entry is None:
invalid_entries += 1
continue
bans[entry.ip] = entry
if invalid_entries:
raise RuntimeError(
f"Ban file {self._path} contains {invalid_entries} invalid entr{'y' if invalid_entries == 1 else 'ies'}."
)
self._bans = bans
def save(self) -> None:
with self._lock:
if self._path is None:
return
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"banned_ips": [
entry.to_dict()
for entry in sorted(self._bans.values(), key=lambda entry: entry.ip)
]
}
fd, temporary_name = tempfile.mkstemp(
prefix=f".{self._path.name}.",
suffix=".tmp",
dir=str(self._path.parent),
text=True,
)
temporary_path = Path(temporary_name)
try:
if os.name != "nt":
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False, allow_nan=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_path, self._path)
if os.name != "nt":
os.chmod(self._path, 0o600)
except Exception:
try:
os.close(fd)
except OSError:
pass
try:
temporary_path.unlink(missing_ok=True)
except OSError:
pass
raise
def is_banned(self, ip: str) -> bool:
try:
normalized = self._normalize_ip(ip)
except ValueError:
return False
with self._lock:
return normalized in self._bans
def get_ban(self, ip: str) -> BanEntry | None:
try:
normalized = self._normalize_ip(ip)
except ValueError:
return None
with self._lock:
return self._bans.get(normalized)
def ban_ip(self, ip: str, reason: str = "") -> BanEntry:
normalized = self._normalize_ip(ip)
entry = BanEntry(ip=normalized, reason=str(reason or "")[:1024], banned_at=time.time())
with self._lock:
self._bans[normalized] = entry
self.save()
return entry
def unban_ip(self, ip: str) -> bool:
try:
normalized = self._normalize_ip(ip)
except ValueError:
return False
with self._lock:
if normalized not in self._bans:
return False
del self._bans[normalized]
self.save()
return True
def list_bans(self) -> list[BanEntry]:
with self._lock:
return [
BanEntry(ip=entry.ip, reason=entry.reason, banned_at=entry.banned_at)
for entry in sorted(self._bans.values(), key=lambda entry: entry.ip)
]
-95
View File
@@ -1,95 +0,0 @@
from __future__ import annotations
import socket
import threading
from dataclasses import dataclass, field
from typing import Any
from packet_codec import EncodedPacket
from tcp_transport import send_message
@dataclass
class ClientSession:
connection: Any
address: tuple[str, int]
player_id: int
connected_at: float
last_packet_at: float | None = None
last_transform: dict[str, Any] | None = None
last_transform_monotonic: float | None = None
packets_received: int = 0
packets_sent: int = 0
packets_broadcast: int = 0
gameplay_active: bool = False
protocol_version: int = 0
_lock: Any = field(repr=False, compare=False, default_factory=threading.RLock)
_send_lock: Any = field(repr=False, compare=False, default_factory=threading.Lock)
@property
def label(self) -> str:
return f"{self.address[0]}:{self.address[1]}"
def record_received(self, received_at: float) -> int:
with self._lock:
self.last_packet_at = received_at
self.packets_received += 1
return self.packets_received
def record_transform(self, packet: dict[str, Any], accepted_monotonic: float | None = None) -> None:
with self._lock:
self.last_transform = dict(packet)
self.last_transform_monotonic = accepted_monotonic
def get_last_transform_anchor(self) -> tuple[dict[str, Any] | None, float | None]:
with self._lock:
transform = dict(self.last_transform) if self.last_transform is not None else None
return transform, self.last_transform_monotonic
def record_sent(self, *, broadcast: bool = False) -> None:
with self._lock:
self.packets_sent += 1
if broadcast:
self.packets_broadcast += 1
def mark_gameplay_active(self, protocol_version: int) -> bool:
with self._lock:
if self.gameplay_active:
return False
self.gameplay_active = True
self.protocol_version = int(protocol_version)
return True
def send_bytes(self, payload: bytes) -> None:
with self._send_lock:
self.connection.sendall(payload)
def send_packet(self, encoded: EncodedPacket) -> None:
"""Send one already-serialized protocol message through the active transport.
TCP compatibility adds its newline delimiter here. Message-oriented
transports can expose ``send_encoded`` and receive the raw JSON payload
plus its delivery policy without TCP framing leaking into the protocol.
"""
with self._send_lock:
send_encoded = getattr(self.connection, "send_encoded", None)
if callable(send_encoded):
send_encoded(encoded)
return
send_message(self.connection, encoded.payload)
def to_snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"playerId": self.player_id,
"address": self.address[0],
"port": self.address[1],
"connectedAt": self.connected_at,
"lastPacketAt": self.last_packet_at,
"lastTransform": dict(self.last_transform) if self.last_transform is not None else None,
"packetsReceived": self.packets_received,
"packetsSent": self.packets_sent,
"packetsBroadcast": self.packets_broadcast,
"gameplayActive": self.gameplay_active,
"protocolVersion": self.protocol_version,
}
+6 -6
View File
@@ -8,16 +8,16 @@ 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
ExecStart=/opt/commonwealth-online/CommonwealthOnline.Server 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.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/commonwealth-online
[Install]
WantedBy=multi-user.target
-268
View File
@@ -1,268 +0,0 @@
"""
Configuration model and loading for Commonwealth Online server.
Supports JSON-based configuration files for hosted deployment.
"""
from __future__ import annotations
import json
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Any
SERVER_NAME_MAX_LENGTH = 64
SERVER_DESCRIPTION_MAX_LENGTH = 256
MAX_PLAYERS_HARD_LIMIT = 256
DEFAULT_ADMIN_PORT = 7779
def _parse_bool(value: Any, *, field_name: str, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, int) and not isinstance(value, bool) and value in (0, 1):
return bool(value)
if isinstance(value, str):
text = value.strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off", ""}:
return False
raise ValueError(
f"{field_name} must be a boolean or one of true/false, yes/no, on/off, 1/0"
)
@dataclass
class Config:
"""Server configuration model."""
host: str = "0.0.0.0"
port: int = 7777
server_name: str = "Commonwealth Online Server"
server_description: str = ""
max_players: int = 16
log_verbosity: str = "info"
admin_port: int = DEFAULT_ADMIN_PORT
enable_gns_transport: bool = False
gns_bridge_path: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert config to dictionary."""
return {
"host": self.host,
"port": self.port,
"server_name": self.server_name,
"server_description": self.server_description,
"max_players": self.max_players,
"log_verbosity": self.log_verbosity,
"admin_port": self.admin_port,
"enable_gns_transport": self.enable_gns_transport,
"gns_bridge_path": self.gns_bridge_path,
}
@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
enable_gns_transport = _parse_bool(
data.get("enable_gns_transport"),
field_name="enable_gns_transport",
default=False,
)
raw_bridge_path = data.get("gns_bridge_path")
if raw_bridge_path is None:
gns_bridge_path = None
elif isinstance(raw_bridge_path, str):
normalized_bridge_path = raw_bridge_path.strip()
gns_bridge_path = normalized_bridge_path or None
else:
raise ValueError("gns_bridge_path must be a string or null")
return cls(
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=max_players,
log_verbosity=str(data.get("log_verbosity", "info")),
admin_port=admin_port,
enable_gns_transport=enable_gns_transport,
gns_bridge_path=gns_bridge_path,
)
def load_config(config_path: str | None = None) -> Config:
"""
Load configuration from file or use defaults.
Args:
config_path: Path to config.json file. If None, returns defaults.
Returns:
Config instance.
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()
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with open(path, "r", encoding="utf-8", newline=None) as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("Config file must contain a JSON object at root level.")
return Config.from_dict(data)
def save_config(config: Config, config_path: str) -> None:
"""
Save configuration to a JSON file.
Args:
config: Config instance to save.
config_path: Path to write config.json.
"""
path = Path(config_path)
path.parent.mkdir(parents=True, exist_ok=True)
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:
"""
Generate and save a default configuration file.
Args:
config_path: Path where default config.json will be written.
Returns:
The generated Config instance.
"""
config = Config()
save_config(config, config_path)
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 _is_valid_gns_bind_host(host: str) -> bool:
"""Return True for the strict IPv4 bind form used by the native GNS bridge."""
text = host.strip()
if not text:
return False
try:
socket.inet_pton(socket.AF_INET, text)
return True
except OSError:
return False
def validate_config(config: Config) -> tuple[bool, list[str]]:
"""
Validate configuration values.
Args:
config: Config to validate.
Returns:
(is_valid, list_of_errors). Empty list if valid.
"""
errors: list[str] = []
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"
)
elif config.enable_gns_transport and not _is_valid_gns_bind_host(str(config.host)):
errors.append(
"enable_gns_transport requires host to be an explicit IPv4 bind address "
"such as 0.0.0.0 or 127.0.0.1"
)
if config.port < 1 or config.port > 65535:
errors.append(f"port must be 1-65535, got {config.port}")
if config.admin_port < 1 or config.admin_port > 65535:
errors.append(f"admin_port must be 1-65535, got {config.admin_port}")
elif config.admin_port == config.port:
errors.append("admin_port must differ from the game port")
if not config.server_name:
errors.append("server_name cannot be empty")
elif len(config.server_name) > SERVER_NAME_MAX_LENGTH:
errors.append(
f"server_name must be <= {SERVER_NAME_MAX_LENGTH} characters, "
f"got {len(config.server_name)}"
)
if len(config.server_description) > SERVER_DESCRIPTION_MAX_LENGTH:
errors.append(
f"server_description must be <= {SERVER_DESCRIPTION_MAX_LENGTH} characters, "
f"got {len(config.server_description)}"
)
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
+22 -10
View File
@@ -1,17 +1,29 @@
# Server Config
This folder is for server configuration templates.
The active server config is `../commonwealth-server.json`.
Possible future config values:
Supported fields:
```text
host=0.0.0.0
port=7777
max_players=2
tick_rate=20
```json
{
"host": "0.0.0.0",
"port": 7777,
"server_name": "Commonwealth Online Server",
"server_description": "",
"max_players": 16,
"log_verbosity": "info",
"admin_port": 7779,
"enable_gns_transport": false,
"gns_bridge_path": null
}
```
`0.0.0.0` listens on all network interfaces. Use `127.0.0.1` only if you want
local-only access again.
Generate a default config with:
Do not commit private IPs, tokens, or secrets.
```bash
dotnet run --project ../CommonwealthOnline.Server.csproj -- config init ../commonwealth-server.json
```
`0.0.0.0` listens on all IPv4 interfaces. `127.0.0.1` is local-only.
Do not commit private tokens or runtime `.admin-token` state.
File diff suppressed because it is too large Load Diff
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from config import Config, load_config, validate_config
from server_service import ServerConfig, ServerService
def build_service_config(config: Config, config_path: Path) -> ServerConfig:
return ServerConfig(
host=config.host,
port=config.port,
server_name=config.server_name,
server_description=config.server_description,
max_players=config.max_players,
log_verbosity=config.log_verbosity,
admin_port=config.admin_port,
bans_path=str(config_path.parent / "bans.json"),
enable_gns_transport=config.enable_gns_transport,
gns_bridge_path=config.gns_bridge_path,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Commonwealth Online dedicated server")
parser.add_argument(
"--config",
default="commonwealth-server.json",
help="Path to the server JSON configuration file",
)
args = parser.parse_args(argv)
config_path = Path(args.config).expanduser().resolve()
try:
config = load_config(str(config_path))
except Exception as error:
print(f"[ERROR] Could not load {config_path}: {error}", file=sys.stderr)
return 2
valid, errors = validate_config(config)
if not valid:
for error in errors:
print(f"[ERROR] {error}", file=sys.stderr)
return 2
service = ServerService(build_service_config(config, config_path))
service.add_log_listener(lambda message, **_: print(message, flush=True))
try:
service.serve_forever()
except KeyboardInterrupt:
pass
finally:
service.stop()
return 0
if __name__ == "__main__":
raise SystemExit(main())
-676
View File
@@ -1,676 +0,0 @@
from __future__ import annotations
import sys
import time
from typing import Any
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QApplication,
QAbstractItemView,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QMainWindow,
QPlainTextEdit,
QPushButton,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from fake_player import FakePlayerManager
from server_core import FalloutTogetherServer
from world_state_presets import TIME_PRESETS, WEATHER_PRESETS, format_hhmm_label
REFRESH_INTERVAL_MS = 500
class ServerLogBridge(QObject):
message_received = Signal(str)
class DevServerWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.server = FalloutTogetherServer()
self.log_bridge = ServerLogBridge()
self.log_bridge.message_received.connect(self._append_log)
self.server.add_log_listener(self._handle_server_log)
self.fake_player_manager = FakePlayerManager(
host=self.server.host,
port=self.server.port,
log_callback=self._handle_fake_client_log,
client_snapshot_provider=lambda: self.server.get_clients(),
)
self.stat_labels: dict[str, QLabel] = {}
self.world_state_labels: dict[str, QLabel] = {}
self.selected_fake_client_key: int | None = None
self.setWindowTitle("Commonwealth Online Dev Server")
self.resize(1100, 760)
self.setCentralWidget(self._create_tabs())
self.refresh_timer = QTimer(self)
self.refresh_timer.timeout.connect(self._refresh_server_state)
self.refresh_timer.start(REFRESH_INTERVAL_MS)
self._refresh_server_state()
def closeEvent(self, event: QCloseEvent) -> None:
self.refresh_timer.stop()
self.fake_player_manager.stop_all()
self.server.remove_log_listener(self._handle_server_log)
self.server.stop()
event.accept()
def _create_tabs(self) -> QTabWidget:
tabs = QTabWidget()
tabs.addTab(self._create_server_console_tab(), "Server Console")
tabs.addTab(self._create_fake_clients_tab(), "Fake Clients")
tabs.addTab(self._create_weather_time_tab(), "Weather / Time")
return tabs
def _create_server_console_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.addLayout(self._create_stats_layout())
layout.addWidget(QLabel("Console"))
self.console = QPlainTextEdit()
self.console.setReadOnly(True)
layout.addLayout(self._create_server_button_layout())
layout.addWidget(self.console, stretch=2)
layout.addWidget(QLabel("Connected Clients"))
self.clients_table = QTableWidget()
self.clients_table.setColumnCount(9)
self.clients_table.setHorizontalHeaderLabels(
[
"Player ID",
"Address",
"Port",
"Connected for / connected time",
"Last packet time",
"Packets received",
"Packets broadcast",
"Last cellId",
"Last position",
]
)
self.clients_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.clients_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.clients_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.clients_table, stretch=1)
return tab
def _create_stats_layout(self) -> QGridLayout:
layout = QGridLayout()
stats = [
("status", "Status"),
("host", "Host"),
("port", "Port"),
("uptime", "Uptime"),
("connectedClients", "Connected clients"),
("clientsConnected", "Total clients connected"),
("clientsDisconnected", "Total clients disconnected"),
("packetsReceived", "Packets received"),
("packetsBroadcast", "Packets broadcast"),
]
for index, (key, label_text) in enumerate(stats):
row = index // 3
column = (index % 3) * 2
label = QLabel(f"{label_text}:")
value = QLabel("-")
self.stat_labels[key] = value
layout.addWidget(label, row, column)
layout.addWidget(value, row, column + 1)
return layout
def _create_server_button_layout(self) -> QHBoxLayout:
layout = QHBoxLayout()
self.start_button = QPushButton("Start Server")
self.start_button.clicked.connect(self._start_server)
layout.addWidget(self.start_button)
self.stop_button = QPushButton("Stop Server")
self.stop_button.clicked.connect(self._stop_server)
layout.addWidget(self.stop_button)
self.clear_console_button = QPushButton("Clear Console")
self.clear_console_button.clicked.connect(self.console.clear)
layout.addWidget(self.clear_console_button)
self.copy_console_button = QPushButton("Copy Console")
self.copy_console_button.clicked.connect(self._copy_console)
layout.addWidget(self.copy_console_button)
layout.addStretch()
return layout
def _create_weather_time_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
status_group = QGroupBox("Server World State")
status_layout = QGridLayout(status_group)
status_fields = [
("time", "Current time (HHmm)"),
("weather", "Current weather"),
]
for index, (key, label_text) in enumerate(status_fields):
label = QLabel(f"{label_text}:")
value = QLabel("Not set")
value.setWordWrap(True)
self.world_state_labels[key] = value
status_layout.addWidget(label, index, 0)
status_layout.addWidget(value, index, 1)
layout.addWidget(status_group)
weather_group = QGroupBox("Set Weather")
weather_layout = QGridLayout(weather_group)
for index, (label, fw_console_arg) in enumerate(WEATHER_PRESETS):
button = QPushButton(label)
button.setToolTip(f"Runs `fw {fw_console_arg}` on the current world-state host.")
button.clicked.connect(
lambda _checked=False, console_arg=fw_console_arg: self._set_server_weather(console_arg)
)
weather_layout.addWidget(button, index // 3, index % 3)
layout.addWidget(weather_group)
time_group = QGroupBox("Set Time (set gamehour to HHmm)")
time_layout = QGridLayout(time_group)
for index, (label, hhmm) in enumerate(TIME_PRESETS):
display = f"{label} ({format_hhmm_label(hhmm)})"
button = QPushButton(display)
button.setToolTip(f"Runs `set gamehour to {hhmm}` on all connected clients.")
button.clicked.connect(lambda _checked=False, value=hhmm: self._set_server_time(value))
time_layout.addWidget(button, index // 3, index % 3)
layout.addWidget(time_group)
hint = QLabel(
"Time is sent to all clients. Weather runs `fw <8-digit-id>` on the current world-state host only, "
"then the host relays weather to other clients. Edit preset IDs in "
"server/world_state_presets.py to match what works in your console."
)
hint.setWordWrap(True)
layout.addWidget(hint)
layout.addStretch()
return tab
def _set_server_weather(self, fw_console_arg: str) -> None:
if not self.server.is_running():
self._append_log("Start the server before setting weather.")
return
if self.server.set_server_weather(fw_console_arg):
self._append_log(f"Requested server weather command: fw {fw_console_arg}")
self._refresh_server_state()
def _set_server_time(self, hhmm: str) -> None:
if not self.server.is_running():
self._append_log("Start the server before setting time.")
return
if self.server.set_server_time(hhmm):
self._append_log(f"Requested server time: {hhmm}")
self._refresh_server_state()
def _create_fake_clients_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
button_layout = QHBoxLayout()
self.add_fake_client_button = QPushButton("+ Add Fake Client")
self.add_fake_client_button.clicked.connect(self._add_fake_client)
button_layout.addWidget(self.add_fake_client_button)
self.remove_fake_client_button = QPushButton("- Remove Selected")
self.remove_fake_client_button.clicked.connect(self._remove_selected_fake_client)
button_layout.addWidget(self.remove_fake_client_button)
button_layout.addStretch()
layout.addLayout(button_layout)
action_layout = QHBoxLayout()
self.set_idle_button = QPushButton("Set Idle")
self.set_idle_button.clicked.connect(self._set_selected_fake_client_idle)
action_layout.addWidget(self.set_idle_button)
self.walk_to_player_button = QPushButton("Walk To Player")
self.walk_to_player_button.clicked.connect(self._set_selected_fake_client_walk_to_player)
action_layout.addWidget(self.walk_to_player_button)
self.walk_circle_button = QPushButton("Walk Circle")
self.walk_circle_button.clicked.connect(self._set_selected_fake_client_walk_circle)
action_layout.addWidget(self.walk_circle_button)
action_layout.addStretch()
layout.addLayout(action_layout)
script_action_layout = QHBoxLayout()
self.jump_once_button = QPushButton("Jump Once")
self.jump_once_button.clicked.connect(self._trigger_selected_fake_client_jump_once)
script_action_layout.addWidget(self.jump_once_button)
self.toggle_sneak_button = QPushButton("Toggle Sneak")
self.toggle_sneak_button.clicked.connect(self._toggle_selected_fake_client_sneak)
script_action_layout.addWidget(self.toggle_sneak_button)
self.toggle_crouch_button = QPushButton("Toggle Crouch")
self.toggle_crouch_button.clicked.connect(self._toggle_selected_fake_client_crouch)
script_action_layout.addWidget(self.toggle_crouch_button)
self.leave_cell_button = QPushButton("Leave Cell")
self.leave_cell_button.clicked.connect(self._trigger_selected_fake_client_leave_cell)
script_action_layout.addWidget(self.leave_cell_button)
self.return_to_cell_button = QPushButton("Return To Cell")
self.return_to_cell_button.clicked.connect(self._trigger_selected_fake_client_return_to_cell)
script_action_layout.addWidget(self.return_to_cell_button)
self.teleport_test_button = QPushButton("Teleport Test")
self.teleport_test_button.clicked.connect(self._trigger_selected_fake_client_teleport_test)
script_action_layout.addWidget(self.teleport_test_button)
script_action_layout.addStretch()
layout.addLayout(script_action_layout)
self.fake_clients_table = QTableWidget()
self.fake_clients_table.setColumnCount(6)
self.fake_clients_table.setHorizontalHeaderLabels(
["Name", "Player ID", "Status", "Script", "Cell ID", "Position"]
)
self.fake_clients_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.fake_clients_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.fake_clients_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.fake_clients_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.fake_clients_table)
# TODO: Add future fake-client scripts: Walk Square, Follow Player,
# Sprint Toggle, Weapon Drawn Toggle, Disconnect After Delay, and
# Multi-fake-player choreography.
return tab
def _start_server(self) -> None:
try:
self.server.start()
except OSError as error:
self._append_log(f"Failed to start server on {self.server.host}:{self.server.port}: {error}")
except RuntimeError as error:
self._append_log(f"Failed to start server: {error}")
self._refresh_server_state()
def _stop_server(self) -> None:
if self.server.is_running():
self.fake_player_manager.stop_all()
self.server.stop()
self._append_log("Server stopped.")
self._refresh_server_state()
def _copy_console(self) -> None:
QApplication.clipboard().setText(self.console.toPlainText())
def _add_fake_client(self) -> None:
if not self.server.is_running():
self._append_log("Start the server before adding fake clients.")
return
self.fake_player_manager.add_client()
self._refresh_fake_clients_table()
def _remove_selected_fake_client(self) -> None:
client_key = self._get_selected_fake_client_key()
if client_key is None:
self._append_log("No fake client selected.")
return
self.fake_player_manager.remove_client(client_key)
self._refresh_fake_clients_table()
def _set_selected_fake_client_idle(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.set_client_idle(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _set_selected_fake_client_walk_to_player(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.set_client_walk_to_player(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _set_selected_fake_client_walk_circle(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.set_client_walk_circle(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_jump_once(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_jump_once(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _toggle_selected_fake_client_sneak(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.toggle_client_sneak(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _toggle_selected_fake_client_crouch(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.toggle_client_crouch(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_leave_cell(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_leave_cell(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_return_to_cell(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_return_to_cell(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _trigger_selected_fake_client_teleport_test(self) -> None:
client_key = self._get_selected_fake_client_key_for_control()
if client_key is None:
return
if not self.fake_player_manager.trigger_client_teleport_test(client_key):
self._append_log("Select a fake client first.")
return
self._refresh_fake_clients_table()
def _get_selected_fake_client_key_for_control(self) -> int | None:
client_key = self._get_selected_fake_client_key()
if client_key is None:
snapshots = self.fake_player_manager.get_snapshots()
if len(snapshots) == 1:
client_key = self._get_optional_int(snapshots[0].get("clientKey"))
if client_key is not None:
self.selected_fake_client_key = client_key
self._append_log("No fake client row selected; using the only fake client.")
if client_key is None:
self._append_log("Select a fake client first.")
return None
if not self.server.is_running():
self._append_log("Start the server before controlling fake clients.")
return None
return client_key
def _get_selected_fake_client_key(self) -> int | None:
selected_items = self.fake_clients_table.selectedItems()
if not selected_items:
return None
row = selected_items[0].row()
name_item = self.fake_clients_table.item(row, 0)
if name_item is None:
return None
client_key = name_item.data(Qt.ItemDataRole.UserRole)
try:
selected_key = int(client_key)
self.selected_fake_client_key = selected_key
return selected_key
except (TypeError, ValueError):
return None
def _handle_server_log(self, message: str) -> None:
self.log_bridge.message_received.emit(message)
def _handle_fake_client_log(self, message: str) -> None:
self.log_bridge.message_received.emit(message)
def _append_log(self, message: str) -> None:
timestamp = time.strftime("%H:%M:%S", time.localtime())
self.console.appendPlainText(f"[{timestamp}] {message}")
def _refresh_server_state(self) -> None:
stats = self.server.get_stats()
clients = self.server.get_clients()
is_running = bool(stats.get("isRunning", False))
self.stat_labels["status"].setText("Running" if is_running else "Stopped")
self.stat_labels["host"].setText(self._format_host_stat(stats))
self.stat_labels["port"].setText(str(stats.get("port", "-")))
self.stat_labels["uptime"].setText(self._format_duration(float(stats.get("uptimeSeconds", 0.0))))
self.stat_labels["connectedClients"].setText(str(stats.get("connectedClients", 0)))
self.stat_labels["clientsConnected"].setText(str(stats.get("clientsConnected", 0)))
self.stat_labels["clientsDisconnected"].setText(str(stats.get("clientsDisconnected", 0)))
self.stat_labels["packetsReceived"].setText(str(stats.get("packetsReceived", 0)))
self.stat_labels["packetsBroadcast"].setText(str(stats.get("packetsBroadcast", 0)))
self.start_button.setEnabled(not is_running)
self.stop_button.setEnabled(is_running)
self._refresh_clients_table(clients)
self._refresh_fake_clients_table()
self._refresh_world_state_labels()
def _refresh_world_state_labels(self) -> None:
world_state = self.server.get_server_world_state()
time_label = self.world_state_labels.get("time")
weather_label = self.world_state_labels.get("weather")
if time_label is not None:
hhmm = world_state.get("timeHHmm")
if hhmm:
time_label.setText(f"{hhmm} ({format_hhmm_label(hhmm)})")
else:
time_label.setText("Not set")
if weather_label is not None:
console_arg = world_state.get("weatherConsoleArg")
form_id = world_state.get("weatherFormId")
if console_arg:
suffix = f" (relay 0x{form_id})" if form_id else ""
weather_label.setText(f"fw {console_arg}{suffix}")
else:
weather_label.setText("Not set")
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
self.clients_table.setRowCount(len(clients))
now = time.time()
for row, client in enumerate(clients):
connected_at = self._get_optional_float(client.get("connectedAt"))
last_packet_at = self._get_optional_float(client.get("lastPacketAt"))
last_transform = client.get("lastTransform")
if not isinstance(last_transform, dict):
last_transform = {}
connected_text = "-"
if connected_at is not None:
connected_text = f"{self._format_duration(now - connected_at)} / {self._format_timestamp(connected_at)}"
values = [
client.get("playerId", "-"),
client.get("address", "-"),
client.get("port", "-"),
connected_text,
self._format_timestamp(last_packet_at) if last_packet_at is not None else "-",
client.get("packetsReceived", 0),
client.get("packetsBroadcast", 0),
last_transform.get("cellId", "-"),
self._format_position(last_transform),
]
for column, value in enumerate(values):
self.clients_table.setItem(row, column, self._create_table_item(str(value)))
def _refresh_fake_clients_table(self) -> None:
selected_key = self._get_selected_fake_client_key()
if selected_key is None:
selected_key = self.selected_fake_client_key
snapshots = self.fake_player_manager.get_snapshots()
self.fake_clients_table.setRowCount(len(snapshots))
row_to_select: int | None = None
for row, snapshot in enumerate(snapshots):
client_key = self._get_optional_int(snapshot.get("clientKey"))
player_id = snapshot.get("playerId")
values = [
snapshot.get("name", "-"),
player_id if player_id is not None else "-",
snapshot.get("status", "-"),
snapshot.get("script", "-"),
snapshot.get("cellId", "-"),
self._format_fake_position(snapshot.get("position")),
]
for column, value in enumerate(values):
item = self._create_table_item(str(value))
if column == 0:
item.setData(Qt.ItemDataRole.UserRole, client_key)
self.fake_clients_table.setItem(row, column, item)
if client_key is not None and client_key == selected_key:
row_to_select = row
if row_to_select is not None:
self.fake_clients_table.selectRow(row_to_select)
self.selected_fake_client_key = selected_key
@staticmethod
def _create_table_item(value: str) -> QTableWidgetItem:
return QTableWidgetItem(value)
@staticmethod
def _get_optional_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _get_optional_float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _format_host_stat(stats: dict[str, Any]) -> str:
host = str(stats.get("host", "-"))
port = stats.get("port")
lan_addresses = stats.get("lanAddresses") or []
if not lan_addresses or port is None:
return host
connect_targets = ", ".join(f"{address}:{port}" for address in lan_addresses)
return f"{host} (LAN: {connect_targets})"
@staticmethod
def _format_duration(seconds: float) -> str:
total_seconds = max(0, int(seconds))
hours, remainder = divmod(total_seconds, 3600)
minutes, secs = divmod(remainder, 60)
if hours:
return f"{hours:d}:{minutes:02d}:{secs:02d}"
return f"{minutes:d}:{secs:02d}"
@staticmethod
def _format_timestamp(timestamp: float) -> str:
return time.strftime("%H:%M:%S", time.localtime(timestamp))
@staticmethod
def _format_position(transform: dict[str, Any]) -> str:
try:
x = float(transform["x"])
y = float(transform["y"])
z = float(transform["z"])
except (KeyError, TypeError, ValueError):
return "-"
return f"{x:.2f}, {y:.2f}, {z:.2f}"
@staticmethod
def _format_fake_position(position: Any) -> str:
try:
x, y, z = position
return f"{float(x):.1f}, {float(y):.1f}, {float(z):.1f}"
except (TypeError, ValueError):
return "-"
def main() -> int:
app = QApplication(sys.argv)
window = DevServerWindow()
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())
-617
View File
@@ -1,617 +0,0 @@
from __future__ import annotations
import json
import math
import socket
import time
from typing import Any
HOST = "127.0.0.1"
PORT = 7777
remote_players: dict[int, dict[str, Any]] = {}
host_world_state: dict[str, Any] = {}
world_state_host_player_id: int | None = None
received_combat_hits: list[dict[str, Any]] = []
remote_npcs: dict[int, dict[str, Any]] = {}
def log(message: str) -> None:
print(message, flush=True)
def warn(message: str) -> None:
log(f"Warning: {message}")
def get_optional_uint32(packet: dict[str, Any], field_name: str, default: int = 0) -> int:
"""Get optional unsigned 32-bit integer field."""
value = packet.get(field_name, default)
try:
parsed_value = int(value)
if 0 <= parsed_value <= 0xFFFFFFFF:
return parsed_value
except (TypeError, ValueError):
pass
return default
def get_optional_bool(packet: dict[str, Any], field_name: str, default: bool = False) -> bool:
value = packet.get(field_name, default)
if isinstance(value, bool):
return value
return default
def get_optional_float(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
value = packet.get(field_name, default)
try:
parsed_value = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(parsed_value) or parsed_value < 0.0:
return default
return parsed_value
def get_optional_game_time(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
value = packet.get(field_name, default)
try:
parsed_value = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(parsed_value):
return default
return parsed_value
def get_optional_action_events(packet: dict[str, Any]) -> list[dict[str, Any]]:
value = packet.get("actionEvents", [])
if not isinstance(value, list):
return []
action_events: list[dict[str, Any]] = []
for event in value:
if not isinstance(event, dict):
continue
event_name = event.get("eventName", "")
if not isinstance(event_name, str) or not event_name:
continue
action_events.append(
{
"sequence": get_optional_uint32(event, "sequence"),
"type": get_optional_uint32(event, "type"),
"eventName": event_name,
}
)
return action_events
def get_optional_equipped_items(packet: dict[str, Any]) -> list[dict[str, str]]:
value = packet.get("equippedItems", [])
if not isinstance(value, list):
return []
equipped_items: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
continue
slot = item.get("slot")
form_id = item.get("formId", "")
if isinstance(slot, str) and isinstance(form_id, str):
equipped_items.append({"slot": slot, "formId": form_id})
return equipped_items
def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
value = packet.get("appearance")
if not isinstance(value, dict):
return None
appearance: dict[str, Any] = {
"version": get_optional_uint32(value, "version", 1),
"raceFormId": value.get("raceFormId", ""),
"height": get_optional_float(value, "height", 1.0),
"morphWeight": value.get("morphWeight", {}),
"bodyTintColor": value.get("bodyTintColor", {}),
"hairColorFormId": value.get("hairColorFormId", ""),
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
"complexionFormId": value.get("complexionFormId", ""),
"headParts": [],
"morphs": [],
"morphRegions": [],
"facialBoneMorphs": [],
"tints": [],
}
if isinstance(value.get("isFemale"), bool):
appearance["isFemale"] = value["isFemale"]
head_parts = value.get("headParts", [])
if isinstance(head_parts, list):
appearance["headParts"] = [form_id for form_id in head_parts if isinstance(form_id, str)]
morphs = value.get("morphs", [])
if isinstance(morphs, list):
appearance["morphs"] = [
{"id": morph.get("id", ""), "value": get_optional_float(morph, "value")}
for morph in morphs
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
]
# Added in appearance version 2; older senders omit these and the lists stay empty.
morph_regions = value.get("morphRegions", [])
if isinstance(morph_regions, list):
appearance["morphRegions"] = [
get_optional_float({"v": entry}, "v")
for entry in morph_regions
if isinstance(entry, (int, float))
]
facial_bone_morphs = value.get("facialBoneMorphs", [])
if isinstance(facial_bone_morphs, list):
appearance["facialBoneMorphs"] = [
{
"id": morph.get("id", ""),
"position": _read_float3(morph.get("position")),
"rotation": _read_float3(morph.get("rotation")),
"scale": _read_float3(morph.get("scale"), default=1.0),
}
for morph in facial_bone_morphs
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
]
# Added in appearance version 3; palette-only color/swatch are preserved when present.
tints = value.get("tints", [])
if isinstance(tints, list):
parsed_tints: list[dict[str, Any]] = []
for tint in tints:
if not isinstance(tint, dict) or not isinstance(tint.get("id"), (int, float)):
continue
entry: dict[str, Any] = {
"id": int(tint.get("id", 0)),
"type": int(tint["type"]) if isinstance(tint.get("type"), (int, float)) else 0,
"value": int(tint["value"]) if isinstance(tint.get("value"), (int, float)) else 0,
}
color = tint.get("color")
if isinstance(color, str) and color:
entry["color"] = color
if isinstance(tint.get("swatch"), (int, float)):
entry["swatch"] = int(tint["swatch"])
parsed_tints.append(entry)
appearance["tints"] = parsed_tints
return appearance
def _read_float3(value: Any, default: float = 0.0) -> list[float]:
result = [default, default, default]
if isinstance(value, list):
for index in range(min(len(value), 3)):
element = value[index]
if isinstance(element, (int, float)):
result[index] = float(element)
return result
def update_host_world_state(packet: dict[str, Any]) -> None:
global host_world_state
host_world_state = {
"gameHour": get_optional_game_time(packet, "gameHour"),
"gameDaysPassed": get_optional_game_time(packet, "gameDaysPassed"),
"weatherFormId": packet.get("weatherFormId", ""),
"timeSync": packet.get("timeSync", False),
"clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime", 0.0),
"lastReceivedLocalTime": time.time(),
}
log("Updated host world state")
print_host_world_state(host_world_state)
def print_host_world_state(world_state: dict[str, Any]) -> None:
client_time = world_state["clientTime"] if world_state["clientTime"] is not None else "missing from packet"
server_time = world_state["serverTime"] if world_state["serverTime"] is not None else "missing from packet"
weather_form_id = world_state["weatherFormId"] or "<none>"
log(
"\n".join(
[
f"Game Hour: {world_state['gameHour']:.3f}",
f"Game Days Passed: {world_state['gameDaysPassed']:.3f}",
f"Weather Form ID: {weather_form_id}",
f"Time Sync: {world_state.get('timeSync', False)}",
f"Client Time: {client_time}",
f"Last Server Time: {server_time}",
f"Last Received Local Time: {world_state['lastReceivedLocalTime']}",
]
)
)
def update_world_state_host(packet: dict[str, Any]) -> None:
global world_state_host_player_id
host_id = packet.get("worldStateHostPlayerId")
if host_id is None:
warn(f"Ignoring worldStateHost packet without worldStateHostPlayerId: {packet}")
return
try:
world_state_host_player_id = int(host_id)
except (TypeError, ValueError):
warn(f"Ignoring worldStateHost packet with invalid worldStateHostPlayerId: {packet}")
return
log(f"World-state host reassigned to player {world_state_host_player_id}")
def update_remote_player_state(packet: dict[str, Any]) -> None:
required_fields = (
"type",
"playerId",
"x",
"y",
"z",
"angleZ",
"cellId",
)
missing_fields = [field_name for field_name in required_fields if field_name not in packet]
if missing_fields:
warn(f"Ignoring transform packet missing required field {missing_fields[0]}: {packet}")
return
try:
player_id = int(packet["playerId"])
x = float(packet["x"])
y = float(packet["y"])
z = float(packet["z"])
angle_z = float(packet["angleZ"])
except (TypeError, ValueError) as error:
warn(f"Ignoring transform packet with invalid numeric fields: {error}: {packet}")
return
existing = remote_players.get(player_id)
character_name = packet.get("characterName", "")
if not isinstance(character_name, str):
character_name = ""
if not character_name and existing and isinstance(existing.get("characterName"), str):
character_name = existing["characterName"]
remote_players[player_id] = {
"playerId": player_id,
"x": x,
"y": y,
"z": z,
"angleZ": angle_z,
"movementType": packet.get("movementType", "normal"),
"cellId": packet["cellId"],
"worldspaceId": packet.get("worldspaceId", ""),
"characterName": character_name,
"clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime", 0.0),
"isMoving": get_optional_bool(packet, "isMoving"),
"isSprinting": get_optional_bool(packet, "isSprinting"),
"isSneaking": get_optional_bool(packet, "isSneaking"),
"isJumping": get_optional_bool(packet, "isJumping"),
"isCrouching": get_optional_bool(packet, "isCrouching"),
"weaponDrawn": get_optional_bool(packet, "weaponDrawn"),
"movementSpeed": get_optional_float(packet, "movementSpeed"),
"actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"),
"actorStateFlags2": get_optional_uint32(packet, "actorStateFlags2"),
"equippedItems": get_optional_equipped_items(packet),
"actionEvents": get_optional_action_events(packet),
"appearance": get_optional_appearance(packet),
"lastReceivedLocalTime": time.time(),
}
log(f"Updated remote player {player_id}")
print_remote_player(remote_players[player_id])
def print_remote_player(player: dict[str, Any]) -> None:
client_time = player["clientTime"] if player["clientTime"] is not None else "missing from packet"
server_time = player["serverTime"] if player["serverTime"] is not None else "missing from packet"
equipped_items = player.get("equippedItems", [])
equipment_text = (
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in equipped_items)
if equipped_items
else "<not sent>"
)
appearance = player.get("appearance")
appearance_text = (
(
f"version={appearance.get('version')}, race={appearance.get('raceFormId') or '<empty>'}, "
f"isFemale={appearance.get('isFemale', '<omitted>')}, "
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
f"morphs={len(appearance.get('morphs', []))}, "
f"morphRegions={len(appearance.get('morphRegions', []))}, "
f"facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, "
f"tints={len(appearance.get('tints', []))}"
)
if isinstance(appearance, dict)
else "<not sent>"
)
action_events = player.get("actionEvents", [])
action_events_text = (
", ".join(
f"seq={event['sequence']}, type={event['type']}, event={event['eventName']}"
for event in action_events
)
if action_events
else "<not sent>"
)
log(
"\n".join(
[
f"Position: X={player['x']:.2f}, Y={player['y']:.2f}, Z={player['z']:.2f}",
f"AngleZ: {player['angleZ']:.2f}",
f"Movement Type: {player['movementType']}",
f"Character Name: {player.get('characterName') or '<not sent>'}",
(
"Movement State: "
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}"
),
f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}",
f"Equipment: {equipment_text}",
f"Action Events: {action_events_text}",
f"Appearance: {appearance_text}",
f"Cell: {player['cellId']}",
f"Worldspace: {player['worldspaceId']}",
f"Client Time: {client_time}",
f"Last Server Time: {server_time}",
f"Last Received Local Time: {player['lastReceivedLocalTime']}",
]
)
)
def print_remote_players() -> None:
if not remote_players:
log("No remote players currently tracked.")
return
log("Known remote players:")
for player_id in sorted(remote_players):
player = remote_players[player_id]
equipment_text = (
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in player.get("equippedItems", []))
if player.get("equippedItems")
else "<not sent>"
)
appearance = player.get("appearance")
appearance_text = (
f"race={appearance.get('raceFormId') or '<empty>'}, isFemale={appearance.get('isFemale', '<omitted>')}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}, morphRegions={len(appearance.get('morphRegions', []))}, facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, tints={len(appearance.get('tints', []))}"
if isinstance(appearance, dict)
else "<not sent>"
)
log(
f"- Player {player_id}: "
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), "
f"angleZ={player['angleZ']:.2f}, movementType={player['movementType']}, "
f"moving={player['isMoving']}, sprinting={player['isSprinting']}, "
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, "
f"equipment={equipment_text}, "
f"appearance={appearance_text}, "
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
f"serverTime={player['serverTime']}"
)
def handle_disconnect_packet(packet: dict[str, Any]) -> None:
player_id = packet.get("playerId")
if player_id is None:
warn(f"Ignoring disconnect packet without playerId: {packet}")
return
try:
player_id = int(player_id)
except (TypeError, ValueError):
warn(f"Ignoring disconnect packet with invalid playerId: {packet}")
return
removed_player = remote_players.pop(player_id, None)
if removed_player is None:
log(f"Received disconnect for unknown remote player {player_id}.")
return
log(f"Removed remote player {player_id} after disconnect.")
print_remote_players()
def handle_session_ended_packet(packet: dict[str, Any]) -> None:
code = packet.get("code", "")
reason = packet.get("reason", "")
reason_text = f", reason={reason!r}" if reason else ""
log(f"Received sessionEnded: code={code!r}{reason_text}")
def handle_combat_hit_packet(packet: dict[str, Any]) -> None:
sender_id = get_optional_uint32(packet, "playerId")
target_player_id = get_optional_uint32(packet, "targetPlayerId")
sequence = get_optional_uint32(packet, "sequence")
damage = get_optional_float(packet, "damage", -1.0)
if sender_id == 0 or target_player_id == 0 or sequence == 0 or damage <= 0.0:
warn(f"Ignoring malformed combatHit packet: {packet}")
return
received_combat_hits.append(dict(packet))
log(
f"Received combatHit: sender={sender_id}, target={target_player_id}, "
f"sequence={sequence}, damage={damage:.1f}"
)
def update_remote_npc_state(packet: dict[str, Any]) -> None:
npcs = packet.get("npcs")
if not isinstance(npcs, list):
warn(f"Ignoring npcState packet without an npcs array: {packet}")
return
next_snapshot: dict[int, dict[str, Any]] = {}
for npc in npcs[:16]:
if not isinstance(npc, dict):
continue
npc_id = get_optional_uint32(npc, "npcId")
base_form_id = npc.get("baseFormId")
if npc_id == 0 or not isinstance(base_form_id, str):
continue
next_snapshot[npc_id] = dict(npc)
remote_npcs.clear()
remote_npcs.update(next_snapshot)
log(
f"Updated host NPC snapshot: count={len(remote_npcs)}, "
f"npcIds={sorted(remote_npcs)}"
)
def handle_packet(packet: dict[str, Any]) -> None:
packet_type = packet.get("type")
if packet_type == "welcome":
host_id = packet.get("worldStateHostPlayerId")
server_name = packet.get("serverName")
server_description = packet.get("serverDescription")
meta_parts = [f"playerId={packet.get('playerId')}"]
if server_name is not None:
meta_parts.append(f"serverName={server_name!r}")
if server_description is not None:
meta_parts.append(f"serverDescription={server_description!r}")
if host_id is not None:
try:
global world_state_host_player_id
world_state_host_player_id = int(host_id)
meta_parts.append(f"worldStateHostPlayerId={world_state_host_player_id}")
log(f"Welcome packet: {', '.join(meta_parts)}")
except (TypeError, ValueError):
log(f"Welcome packet: {packet}")
else:
log(f"Welcome packet: {', '.join(meta_parts)}")
elif packet_type == "transform":
log(f"Received transform: {packet}")
update_remote_player_state(packet)
elif packet_type == "worldState":
log(f"Received worldState: {packet}")
update_host_world_state(packet)
elif packet_type == "serverWorldState":
log(f"Received serverWorldState: {packet}")
elif packet_type == "worldStateHost":
log(f"Received worldStateHost: {packet}")
update_world_state_host(packet)
elif packet_type == "disconnect":
handle_disconnect_packet(packet)
elif packet_type == "sessionEnded":
handle_session_ended_packet(packet)
elif packet_type == "combatHit":
handle_combat_hit_packet(packet)
elif packet_type == "npcState":
update_remote_npc_state(packet)
else:
log(f"Received packet: {packet}")
def handle_line(line: str) -> None:
if not line:
return
try:
packet = json.loads(line)
except json.JSONDecodeError as error:
log(f"Invalid JSON from server: {error}: {line}")
return
if not isinstance(packet, dict):
log(f"Invalid packet from server: expected JSON object: {packet}")
return
handle_packet(packet)
def send_ranged_fire_action(socket_connection: socket.socket, sequence: int) -> None:
"""Send a simulated ranged fire action event (type=3, eventName=fireSingle)."""
transform_packet = {
"type": "transform",
"x": -1792.0,
"y": -1344.0,
"z": 0.0,
"angleZ": 3.93,
"movementType": "normal",
"isMoving": False,
"isSprinting": False,
"isSneaking": False,
"isJumping": False,
"isCrouching": False,
"weaponDrawn": True,
"movementSpeed": 0.0,
"actionEvents": [
{
"sequence": sequence,
"type": 3,
"eventName": "fireSingle",
}
],
}
packet_line = json.dumps(transform_packet, separators=(",", ":"))
socket_connection.sendall((packet_line + "\n").encode("utf-8"))
log(f"Sent ranged fire action: sequence={sequence}, type=3, eventName=fireSingle")
def send_combat_hit(socket_connection: socket.socket, target_player_id: int, sequence: int, damage: float, weapon_form_id: int = 0) -> None:
"""Send a targeted combatHit packet."""
packet = {
"type": "combatHit",
"sequence": sequence,
"targetPlayerId": target_player_id,
"damage": damage,
}
if weapon_form_id != 0:
packet["weaponFormId"] = f"{weapon_form_id:08X}"
packet_line = json.dumps(packet, separators=(",", ":"))
socket_connection.sendall((packet_line + "\n").encode("utf-8"))
log(f"Sent combatHit: targetPlayerId={target_player_id}, damage={damage:.1f}, weaponFormId={weapon_form_id:08X}")
def main() -> None:
# This fake client lets us verify server broadcast behavior before trying to
# run and coordinate a second Fallout 4/F4SE instance.
with socket.create_connection((HOST, PORT)) as connection:
log(f"Connected to Commonwealth Online server at {HOST}:{PORT}")
buffer = ""
while True:
chunk = connection.recv(4096)
if not chunk:
log("Server disconnected.")
break
buffer += chunk.decode("utf-8", errors="replace")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
handle_line(line.strip())
if __name__ == "__main__":
try:
main()
except ConnectionRefusedError:
log(f"Could not connect to Commonwealth Online server at {HOST}:{PORT}. Is server.py running?")
except KeyboardInterrupt:
log("\nFake client stopped.")
except OSError as error:
log(f"Disconnected from server: {error}")
-980
View File
@@ -1,980 +0,0 @@
from __future__ import annotations
import json
import math
import socket
import threading
import time
from collections.abc import Callable
from typing import Any
HOST = "127.0.0.1"
PORT = 7777
SCRIPT_IDLE = "Idle"
SCRIPT_WALK_TO_PLAYER = "Walk To Player"
SCRIPT_WALK_CIRCLE = "Walk Circle"
SCRIPT_LEFT_CELL = "Left Cell"
STATUS_CONNECTING = "Connecting"
STATUS_CONNECTED = "Connected"
STATUS_DISCONNECTED = "Disconnected"
STATUS_ERROR = "Error"
IDLE_SEND_INTERVAL_SECONDS = 1.0
WALK_TO_PLAYER_SEND_RATE_HZ = 10.0
WALK_TO_PLAYER_SPEED = 180.0
WALK_TO_PLAYER_STOP_DISTANCE = 100.0
WALK_TO_PLAYER_OFFSET_X = 150.0
WALK_CIRCLE_RADIUS = 300.0
WALK_CIRCLE_SPEED = 180.0
WALK_CIRCLE_SEND_RATE_HZ = 10.0
JUMP_DURATION_SECONDS = 0.8
JUMP_HEIGHT = 120.0
JUMP_SEND_RATE_HZ = 10.0
NO_TARGET_LOG_INTERVAL_SECONDS = 5.0
SOCKET_TIMEOUT_SECONDS = 0.05
STOP_JOIN_TIMEOUT_SECONDS = 2.0
IDLE_CELL_ID = "0B000F99"
IDLE_WORLDSPACE_ID = ""
LEFT_CELL_ID = "DEADBEEF"
IDLE_X = -1792.0
IDLE_Y = -1344.0
IDLE_Z = 0.0
IDLE_ANGLE_Z = 3.93
TELEPORT_TEST_OFFSET_X = 900.0
TELEPORT_TEST_OFFSET_Y = 450.0
DEFAULT_APPEARANCE = {
"version": 4,
"raceFormId": "00013746",
"height": 1.0,
"isFemale": False,
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
"bodyTintColor": {"r": 255, "g": 220, "b": 190, "a": 255},
"hairColorFormId": "",
"facialHairColorFormId": "",
"complexionFormId": "",
"headParts": [],
"morphs": [],
"morphRegions": [],
"facialBoneMorphs": [],
"tints": [],
}
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
WalkTargetProvider = Callable[[int], dict[str, Any] | None]
def map_movement_speed_to_graph_speed(movement_speed: float, *, is_sprinting: bool = False) -> float:
"""Match plugin MapMovementSpeedToGraphSpeed.
FO4 graph "Speed" shares the same world-units/sec scale as movementSpeed, so the
mapping is ~1:1 (clamped). Confirmed from local-player graph debug: run movementSpeed
~306 lines up with graph Speed ~296-373.
"""
if movement_speed < 1.0:
return 0.0
graph_speed = movement_speed
if is_sprinting:
graph_speed = max(graph_speed, 350.0)
return min(graph_speed, 500.0)
class FakePlayerClient:
def __init__(
self,
client_key: int,
name: str,
*,
host: str = HOST,
port: int = PORT,
log_callback: Callable[[str], None] | None = None,
walk_target_provider: WalkTargetProvider | None = None,
) -> None:
self.client_key = client_key
self.name = name
self.host = host
self.port = port
self.script = SCRIPT_IDLE
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
self.angle_z = IDLE_ANGLE_Z
self.is_sneaking = False
self.is_jumping = False
self.is_crouching = False
self._log_callback = log_callback
self._walk_target_provider = walk_target_provider
self._lock = threading.RLock()
self._send_lock = threading.Lock()
self._stop_event = threading.Event()
self._script_changed_event = threading.Event()
self._thread: threading.Thread | None = None
self._socket: socket.socket | None = None
self._player_id: int | None = None
self._status = STATUS_DISCONNECTED
self._last_error: str | None = None
self._last_no_target_log_at = 0.0
self._last_target_player_id: int | None = None
self._reached_target_player_id: int | None = None
self._walk_circle_center = self.position
self._walk_circle_phase = 0.0
self._one_shot_active = False
self._jump_thread: threading.Thread | None = None
self._teleport_count = 0
self._ranged_fire_sequence = 1000
self._combat_hit_sequence = 1000
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._status = STATUS_CONNECTING
self._last_error = None
self._thread = threading.Thread(target=self._run, name=self.name, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._close_socket()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
jump_thread = self._jump_thread
if jump_thread is not None and jump_thread is not threading.current_thread():
jump_thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
with self._lock:
if self._status != STATUS_ERROR:
self._status = STATUS_DISCONNECTED
def set_idle(self) -> None:
self._set_script(SCRIPT_IDLE)
def set_walk_to_player(self) -> None:
self._set_script(SCRIPT_WALK_TO_PLAYER)
def set_walk_circle(self) -> None:
with self._lock:
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
self._walk_circle_center = self.position
self._walk_circle_phase = 0.0
self._set_script(SCRIPT_WALK_CIRCLE)
def trigger_jump_once(self) -> None:
with self._lock:
if self._one_shot_active:
return
self._one_shot_active = True
previous_script = self.script
thread = threading.Thread(
target=self._run_jump_once,
args=(previous_script,),
name=f"{self.name} Jump Once",
daemon=True,
)
self._jump_thread = thread
self._log(f"{self.name} triggered Jump Once.")
thread.start()
def toggle_sneak(self) -> bool:
with self._lock:
self.is_sneaking = not self.is_sneaking
is_sneaking = self.is_sneaking
self._script_changed_event.set()
self._log(f"{self.name} toggled sneak: {str(is_sneaking).lower()}.")
return is_sneaking
def toggle_crouch(self) -> bool:
with self._lock:
self.is_crouching = not self.is_crouching
is_crouching = self.is_crouching
self._script_changed_event.set()
self._log(f"{self.name} toggled crouch: {str(is_crouching).lower()}.")
return is_crouching
def trigger_leave_cell(self) -> None:
with self._lock:
self.script = SCRIPT_LEFT_CELL
self.cell_id = LEFT_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
self._last_no_target_log_at = 0.0
self._last_target_player_id = None
self._reached_target_player_id = None
self._script_changed_event.set()
self._send_immediate_transform(
movement_type="cell_change",
is_moving=False,
movement_speed=0.0,
)
self._log(f"{self.name} left test cell.")
def trigger_return_to_cell(self) -> None:
with self._lock:
self.script = SCRIPT_IDLE
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
self.position = (IDLE_X, IDLE_Y, IDLE_Z)
self.angle_z = IDLE_ANGLE_Z
self._last_no_target_log_at = 0.0
self._last_target_player_id = None
self._reached_target_player_id = None
self._script_changed_event.set()
self._send_immediate_transform(
movement_type="cell_change",
is_moving=False,
movement_speed=0.0,
)
self._log(f"{self.name} returned to test cell.")
def trigger_teleport_test(self) -> None:
with self._lock:
self.script = SCRIPT_IDLE
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
current_x, current_y, current_z = self.position
direction = -1.0 if self._teleport_count % 2 else 1.0
self._teleport_count += 1
self.position = (
current_x + (TELEPORT_TEST_OFFSET_X * direction),
current_y + (TELEPORT_TEST_OFFSET_Y * direction),
current_z,
)
self._last_no_target_log_at = 0.0
self._last_target_player_id = None
self._reached_target_player_id = None
self._script_changed_event.set()
self._send_immediate_transform(
movement_type="teleport",
is_moving=False,
movement_speed=0.0,
)
self._log(f"{self.name} triggered Teleport Test.")
def send_ranged_fire_action(self) -> None:
"""Send a simulated ranged fire action event (type=3, eventName=fireSingle)."""
with self._lock:
self._ranged_fire_sequence += 1
sequence = self._ranged_fire_sequence
connection = self._socket
is_connected = self._status == STATUS_CONNECTED
if connection is None or not is_connected:
self._log(f"{self.name}: cannot send ranged fire action while disconnected.")
return
self._send_transform(
connection,
is_moving=False,
movement_speed=0.0,
weapon_drawn=True,
action_events=[
{
"sequence": sequence,
"type": 3,
"eventName": "fireSingle",
}
],
)
self._log(f"{self.name} sent ranged fire action: sequence={sequence}")
def send_combat_hit(self, target_player_id: int, damage: float, weapon_form_id: int = 0) -> None:
"""Send a targeted combatHit packet to another player."""
with self._lock:
self._combat_hit_sequence += 1
sequence = self._combat_hit_sequence
packet = {
"type": "combatHit",
"sequence": sequence,
"targetPlayerId": target_player_id,
"damage": damage,
}
if weapon_form_id != 0:
packet["weaponFormId"] = f"{weapon_form_id:08X}"
try:
with self._lock:
connection = self._socket
is_connected = self._status == STATUS_CONNECTED
if connection is None or not is_connected:
self._log(f"{self.name} combat hit send failed: not connected")
return
packet_line = json.dumps(packet, separators=(",", ":"))
with self._send_lock:
connection.sendall((packet_line + "\n").encode("utf-8"))
self._log(f"{self.name} sent combatHit to player {target_player_id}: damage={damage:.1f}")
except Exception as error:
self._log(f"{self.name} error sending combatHit: {error}")
def get_snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"clientKey": self.client_key,
"name": self.name,
"playerId": self._player_id,
"status": self._status,
"script": self.script,
"cellId": self.cell_id,
"position": self.position,
"lastError": self._last_error,
}
def _set_script(self, script: str) -> None:
with self._lock:
self.script = script
self._last_no_target_log_at = 0.0
self._last_target_player_id = None
self._reached_target_player_id = None
self._script_changed_event.set()
self._log(f"{self.name} script set to {script}.")
def _run(self) -> None:
self._log(f"{self.name} connecting...")
try:
connection = socket.create_connection((self.host, self.port), timeout=5.0)
connection.settimeout(SOCKET_TIMEOUT_SECONDS)
with self._lock:
self._socket = connection
self._receive_and_send_loop(connection)
except (ConnectionRefusedError, TimeoutError, OSError) as error:
if not self._stop_event.is_set():
self._set_error(str(error))
self._log(f"{self.name} error: {error}")
finally:
self._close_socket()
with self._lock:
if self._status != STATUS_ERROR:
self._status = STATUS_DISCONNECTED
self._log(f"{self.name} disconnected.")
def _receive_and_send_loop(self, connection: socket.socket) -> None:
buffer = ""
now = time.monotonic()
next_transform_time = now
last_transform_time = now
while not self._stop_event.is_set():
chunk: bytes | None
try:
chunk = connection.recv(4096)
except socket.timeout:
chunk = None
except ConnectionResetError:
break
except OSError:
if not self._stop_event.is_set():
raise
break
if chunk is None:
pass
elif chunk:
buffer += chunk.decode("utf-8", errors="replace")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
self._handle_line(line.strip())
else:
break
now = time.monotonic()
if self._script_changed_event.is_set():
self._script_changed_event.clear()
next_transform_time = now
last_transform_time = now
if self._is_connected() and now >= next_transform_time:
delta_seconds = max(0.0, now - last_transform_time)
interval_seconds = self._send_script_transform(connection, delta_seconds)
last_transform_time = now
next_transform_time = now + interval_seconds
def _handle_line(self, line: str) -> None:
if not line:
return
try:
packet = json.loads(line)
except json.JSONDecodeError as error:
self._log(f"{self.name} received invalid JSON: {error}: {line}")
return
if not isinstance(packet, dict):
self._log(f"{self.name} received non-object packet: {packet}")
return
if packet.get("type") == "welcome":
self._handle_welcome(packet)
def _handle_welcome(self, packet: dict[str, Any]) -> None:
try:
player_id = int(packet["playerId"])
except (KeyError, TypeError, ValueError):
self._log(f"{self.name} received malformed welcome packet: {packet}")
return
with self._lock:
self._player_id = player_id
self._status = STATUS_CONNECTED
self._log(f"{self.name} assigned playerId {player_id}.")
def _send_script_transform(self, connection: socket.socket, delta_seconds: float) -> float:
script = self._get_script()
if self._is_one_shot_active():
return SOCKET_TIMEOUT_SECONDS
if script == SCRIPT_WALK_TO_PLAYER:
return self._send_walk_to_player_transform(connection, delta_seconds)
if script == SCRIPT_WALK_CIRCLE:
return self._send_walk_circle_transform(connection, delta_seconds)
self._send_transform(connection, is_moving=False, movement_speed=0.0)
return IDLE_SEND_INTERVAL_SECONDS
def _send_walk_to_player_transform(self, connection: socket.socket, delta_seconds: float) -> float:
target = self._get_walk_to_player_target()
if target is None:
self._log_no_target_if_needed()
self._send_transform(connection, is_moving=False, movement_speed=0.0)
return IDLE_SEND_INTERVAL_SECONDS
target_player_id = target["playerId"]
target_position = (target["x"], target["y"], target["z"])
walk_interval = 1.0 / WALK_TO_PLAYER_SEND_RATE_HZ
step_delta = max(delta_seconds, walk_interval)
with self._lock:
current_x, current_y, current_z = self.position
delta_x = target_position[0] - current_x
delta_y = target_position[1] - current_y
delta_z = target_position[2] - current_z
distance = math.sqrt((delta_x * delta_x) + (delta_y * delta_y) + (delta_z * delta_z))
if self._last_target_player_id != target_player_id:
self._last_target_player_id = target_player_id
self._reached_target_player_id = None
should_log_walking = True
else:
should_log_walking = False
self.cell_id = target["cellId"]
self.worldspace_id = target["worldspaceId"]
if distance <= WALK_TO_PLAYER_STOP_DISTANCE:
is_new_reached = self._reached_target_player_id != target_player_id
self._reached_target_player_id = target_player_id
is_moving = False
movement_speed = 0.0
else:
if self._reached_target_player_id == target_player_id:
should_log_walking = True
self._reached_target_player_id = None
max_step = WALK_TO_PLAYER_SPEED * step_delta
step = min(max_step, distance)
scale = step / distance
new_x = current_x + (delta_x * scale)
new_y = current_y + (delta_y * scale)
new_z = current_z + (delta_z * scale)
self.position = (new_x, new_y, new_z)
self.angle_z = math.atan2(delta_x, delta_y)
is_new_reached = False
is_moving = True
movement_speed = WALK_TO_PLAYER_SPEED
if should_log_walking:
self._log(f"{self.name} walking to player {target_player_id}.")
if is_new_reached:
self._log(f"{self.name} reached player {target_player_id}.")
self._send_transform(connection, is_moving=is_moving, movement_speed=movement_speed)
return walk_interval if is_moving else IDLE_SEND_INTERVAL_SECONDS
def _send_walk_circle_transform(self, connection: socket.socket, delta_seconds: float) -> float:
walk_interval = 1.0 / WALK_CIRCLE_SEND_RATE_HZ
step_delta = max(delta_seconds, walk_interval)
angular_speed = WALK_CIRCLE_SPEED / WALK_CIRCLE_RADIUS
with self._lock:
self.cell_id = IDLE_CELL_ID
self.worldspace_id = IDLE_WORLDSPACE_ID
center_x, center_y, center_z = self._walk_circle_center
self._walk_circle_phase += angular_speed * step_delta
phase = self._walk_circle_phase
new_x = center_x + (WALK_CIRCLE_RADIUS * math.cos(phase))
new_y = center_y + (WALK_CIRCLE_RADIUS * math.sin(phase))
new_z = center_z
tangent_x = -math.sin(phase)
tangent_y = math.cos(phase)
self.position = (new_x, new_y, new_z)
self.angle_z = math.atan2(tangent_x, tangent_y)
self._send_transform(connection, is_moving=True, movement_speed=WALK_CIRCLE_SPEED)
return walk_interval
def _run_jump_once(self, previous_script: str) -> None:
interval_seconds = 1.0 / JUMP_SEND_RATE_HZ
steps = max(2, int(JUMP_DURATION_SECONDS * JUMP_SEND_RATE_HZ))
with self._lock:
ground_x, ground_y, ground_z = self.position
try:
for step_index in range(steps + 1):
if self._stop_event.is_set():
return
progress = step_index / steps
height = JUMP_HEIGHT * (1.0 - abs((2.0 * progress) - 1.0))
with self._lock:
self.position = (ground_x, ground_y, ground_z + height)
self.is_jumping = True
self._send_immediate_transform(
movement_type="normal",
is_moving=False,
movement_speed=0.0,
is_jumping=True,
)
time.sleep(interval_seconds)
finally:
with self._lock:
self.position = (ground_x, ground_y, ground_z)
self.is_jumping = False
self._one_shot_active = False
if self.script == previous_script and previous_script not in (
SCRIPT_WALK_TO_PLAYER,
SCRIPT_WALK_CIRCLE,
SCRIPT_LEFT_CELL,
):
self.script = SCRIPT_IDLE
self._script_changed_event.set()
self._send_immediate_transform(
movement_type="normal",
is_moving=False,
movement_speed=0.0,
is_jumping=False,
)
def _send_immediate_transform(
self,
*,
movement_type: str,
is_moving: bool,
movement_speed: float,
is_jumping: bool | None = None,
is_crouching: bool | None = None,
) -> None:
with self._lock:
connection = self._socket
is_connected = self._status == STATUS_CONNECTED
if connection is None or not is_connected:
return
try:
self._send_transform(
connection,
movement_type=movement_type,
is_moving=is_moving,
movement_speed=movement_speed,
is_jumping=is_jumping,
is_crouching=is_crouching,
)
except OSError as error:
if not self._stop_event.is_set():
self._set_error(str(error))
self._log(f"{self.name} error: {error}")
def send_melee_action_test(self, *, sequence: int = 1, action_type: int = 1) -> None:
"""Inject one basic melee action event for protocol/replay testing."""
with self._lock:
connection = self._socket
is_connected = self._status == STATUS_CONNECTED
if connection is None or not is_connected:
self._log(f"{self.name}: cannot send melee action test while disconnected.")
return
self._send_transform(
connection,
is_moving=False,
movement_speed=0.0,
action_events=[
{
"sequence": sequence,
"type": action_type,
"eventName": "meleeattackStart",
}
],
)
self._log(
f"{self.name}: sent melee action test (sequence={sequence}, type={action_type})."
)
def _send_transform(
self,
connection: socket.socket,
*,
is_moving: bool,
movement_speed: float,
movement_type: str = "normal",
is_jumping: bool | None = None,
is_crouching: bool | None = None,
weapon_drawn: bool = False,
action_events: list[dict[str, Any]] | None = None,
) -> None:
with self._lock:
x, y, z = self.position
angle_z = self.angle_z
cell_id = self.cell_id
worldspace_id = self.worldspace_id
is_sneaking = self.is_sneaking
jumping = self.is_jumping if is_jumping is None else is_jumping
crouching = self.is_crouching if is_crouching is None else is_crouching
packet = {
"type": "transform",
"x": x,
"y": y,
"z": z,
"angleZ": angle_z,
"movementType": movement_type,
"cellId": cell_id,
"worldspaceId": worldspace_id,
"characterName": self.name,
"clientTime": time.time(),
"isMoving": is_moving,
"isSprinting": False,
"isSneaking": is_sneaking,
"isJumping": jumping,
"isCrouching": crouching,
"weaponDrawn": weapon_drawn,
"movementSpeed": movement_speed,
"appearance": DEFAULT_APPEARANCE,
}
if is_moving and movement_speed >= 1.0:
packet["animationGraphSpeed"] = round(
map_movement_speed_to_graph_speed(movement_speed),
1,
)
if action_events:
packet["actionEvents"] = action_events
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
with self._send_lock:
connection.sendall(encoded)
def _get_script(self) -> str:
with self._lock:
return self.script
def _get_walk_to_player_target(self) -> dict[str, Any] | None:
if self._walk_target_provider is None:
return None
return self._walk_target_provider(self.client_key)
def _log_no_target_if_needed(self) -> None:
now = time.monotonic()
with self._lock:
if now - self._last_no_target_log_at < NO_TARGET_LOG_INTERVAL_SECONDS:
return
self._last_no_target_log_at = now
self._last_target_player_id = None
self._reached_target_player_id = None
self._log("No real player target available for Walk To Player.")
def _is_connected(self) -> bool:
with self._lock:
return self._status == STATUS_CONNECTED
def _is_one_shot_active(self) -> bool:
with self._lock:
return self._one_shot_active
def _set_error(self, message: str) -> None:
with self._lock:
self._status = STATUS_ERROR
self._last_error = message
def _close_socket(self) -> None:
with self._lock:
connection = self._socket
self._socket = None
if connection is None:
return
try:
connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
connection.close()
except OSError:
pass
def _log(self, message: str) -> None:
if self._log_callback is None:
return
self._log_callback(message)
class FakePlayerManager:
def __init__(
self,
*,
host: str = HOST,
port: int = PORT,
log_callback: Callable[[str], None] | None = None,
client_snapshot_provider: ClientSnapshotProvider | None = None,
) -> None:
self.host = host
self.port = port
self._log_callback = log_callback
self._client_snapshot_provider = client_snapshot_provider
self._lock = threading.RLock()
self._next_client_key = 1
self._clients: dict[int, FakePlayerClient] = {}
def add_client(self) -> FakePlayerClient:
with self._lock:
client_key = self._next_client_key
self._next_client_key += 1
client = FakePlayerClient(
client_key,
f"Fake Client {client_key}",
host=self.host,
port=self.port,
log_callback=self._log,
walk_target_provider=self.get_walk_to_player_target,
)
self._clients[client_key] = client
client.start()
return client
def remove_client(self, client_key: int) -> None:
with self._lock:
client = self._clients.pop(client_key, None)
if client is not None:
client.stop()
def set_client_idle(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.set_idle()
return True
def set_client_walk_to_player(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.set_walk_to_player()
return True
def set_client_walk_circle(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.set_walk_circle()
return True
def trigger_client_jump_once(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.trigger_jump_once()
return True
def toggle_client_sneak(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.toggle_sneak()
return True
def toggle_client_crouch(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.toggle_crouch()
return True
def trigger_client_leave_cell(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.trigger_leave_cell()
return True
def trigger_client_return_to_cell(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.trigger_return_to_cell()
return True
def trigger_client_teleport_test(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.trigger_teleport_test()
return True
def client_send_ranged_fire_action(self, client_key: int) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.send_ranged_fire_action()
return True
def client_send_combat_hit(self, client_key: int, target_player_id: int, damage: float, weapon_form_id: int = 0) -> bool:
client = self._get_client(client_key)
if client is None:
return False
client.send_combat_hit(target_player_id, damage, weapon_form_id)
return True
def stop_all(self) -> None:
with self._lock:
clients = list(self._clients.values())
self._clients.clear()
for client in clients:
client.stop()
def get_snapshots(self) -> list[dict[str, Any]]:
with self._lock:
clients = list(self._clients.values())
return [client.get_snapshot() for client in clients]
def get_walk_to_player_target(self, client_key: int) -> dict[str, Any] | None:
if self._client_snapshot_provider is None:
return None
try:
server_clients = self._client_snapshot_provider()
except Exception as error:
self._log(f"Could not read server client snapshots: {error}")
return None
fake_player_ids = self._get_fake_player_ids()
target_candidates: list[dict[str, Any]] = []
for client in server_clients:
target = self._parse_walk_target(client)
if target is None or target["playerId"] in fake_player_ids:
continue
target_candidates.append(target)
if not target_candidates:
return None
target_candidates.sort(key=lambda target: target["playerId"])
return target_candidates[0]
def _get_client(self, client_key: int) -> FakePlayerClient | None:
with self._lock:
return self._clients.get(client_key)
def _get_fake_player_ids(self) -> set[int]:
with self._lock:
clients = list(self._clients.values())
fake_player_ids: set[int] = set()
for client in clients:
snapshot = client.get_snapshot()
player_id = self._get_optional_int(snapshot.get("playerId"))
if player_id is not None:
fake_player_ids.add(player_id)
return fake_player_ids
@classmethod
def _parse_walk_target(cls, client: dict[str, Any]) -> dict[str, Any] | None:
player_id = cls._get_optional_int(client.get("playerId"))
last_transform = client.get("lastTransform")
if player_id is None or not isinstance(last_transform, dict):
return None
x = cls._get_finite_float(last_transform.get("x"))
y = cls._get_finite_float(last_transform.get("y"))
z = cls._get_finite_float(last_transform.get("z"))
cell_id = last_transform.get("cellId")
if x is None or y is None or z is None or not cell_id:
return None
worldspace_id = last_transform.get("worldspaceId", "")
if worldspace_id is None:
worldspace_id = ""
return {
"playerId": player_id,
"x": x + WALK_TO_PLAYER_OFFSET_X,
"y": y,
"z": z,
"cellId": str(cell_id),
"worldspaceId": str(worldspace_id),
}
@staticmethod
def _get_optional_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _get_finite_float(value: Any) -> float | None:
try:
parsed_value = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(parsed_value):
return None
return parsed_value
def _log(self, message: str) -> None:
if self._log_callback is None:
return
self._log_callback(f"[FakeClient] {message}")
+24 -84
View File
@@ -1,110 +1,50 @@
@echo off
setlocal enabledelayedexpansion
setlocal EnableExtensions EnableDelayedExpansion
cd /d "%~dp0"
echo.
echo ================================================================================
echo Commonwealth Online - Port 7777 in Use
echo ================================================================================
echo.
echo Port 7777 is currently in use by another process.
echo.
echo Options:
echo 1. Kill the process using port 7777 and restart server
echo 2. Use a different port (you will be prompted to enter one)
echo 3. Cancel and exit
echo.
echo 1. Kill the process using TCP 7777 and restart
echo 2. Start the server on a different port
echo 3. Cancel
choice /C 123 /N /M "Select option (1-3): "
set choice=%errorlevel%
if %choice%==1 goto kill_process
if %choice%==3 exit /b 0
if %choice%==2 goto change_port
if %choice%==3 goto exit_script
:kill_process
echo.
echo Finding process using port 7777...
for /f "tokens=5" %%a in ('netstat -aon ^| find ":7777"') do (
set PID=%%a
)
if defined PID (
echo Killing process with PID !PID!...
taskkill /PID !PID! /F >nul 2>&1
if errorlevel 1 (
echo ERROR: Could not kill process. Try running this script as Administrator.
pause
exit /b 1
)
echo Process killed successfully.
echo.
echo Waiting for port to be released...
timeout /t 2 /nobreak
echo.
echo Restarting server...
call start.bat
) else (
echo Could not determine which process is using port 7777.
echo Try:
echo - Restarting your computer
echo - Running as Administrator
echo - Or choose option 2 to use a different port
pause
set "PID="
for /f "tokens=5" %%A in ('netstat -aon ^| findstr /R /C:":7777 .*LISTENING"') do if not defined PID set "PID=%%A"
if not defined PID (
echo ERROR: Could not determine which process is using TCP 7777.
exit /b 1
)
goto end
echo Killing PID !PID!...
taskkill /PID !PID! /F >nul 2>&1 || (
echo ERROR: Could not kill PID !PID!. Try running as Administrator.
exit /b 1
)
timeout /t 2 /nobreak >nul
call start.bat
exit /b %ERRORLEVEL%
:change_port
echo.
set /p NEW_PORT="Enter desired port (1024-65535, default is 7777): "
if "!NEW_PORT!"=="" (
set NEW_PORT=7777
)
REM Validate port is a number between 1024 and 65535
set /p NEW_PORT="Enter desired port (1024-65535): "
for /f "delims=0123456789" %%A in ("!NEW_PORT!") do (
echo ERROR: Port must be a number.
pause
echo ERROR: Port must be numeric.
exit /b 1
)
if "!NEW_PORT!"=="" exit /b 1
if !NEW_PORT! lss 1024 (
echo ERROR: Port must be 1024 or higher.
pause
exit /b 1
)
if !NEW_PORT! gtr 65535 (
echo ERROR: Port must be 65535 or lower.
pause
exit /b 1
)
echo.
echo Updating commonwealth-server.json to use port !NEW_PORT!...
REM Create new config with updated port
(
echo {
echo "host": "0.0.0.0",
echo "port": !NEW_PORT!,
echo "server_name": "Commonwealth Online Server",
echo "max_players": 16,
echo "log_verbosity": "info"
echo }
) > commonwealth-server.json
echo Config updated. Starting server on port !NEW_PORT!...
echo.
python consumer_server_cli.py serve --config commonwealth-server.json
goto end
:exit_script
echo.
echo Cancelled.
exit /b 0
:end
pause
echo Starting on TCP/UDP !NEW_PORT!. This override does not rewrite commonwealth-server.json.
call start.bat --port !NEW_PORT!
exit /b %ERRORLEVEL%
Regular → Executable
+21 -126
View File
@@ -1,148 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
PORT=7777
echo
echo "================================================================================"
echo " Commonwealth Online - Port ${PORT} in Use"
echo "================================================================================"
echo
echo "Port ${PORT} is currently in use by another process."
echo
echo "Options:"
echo " 1. Kill the process using port ${PORT} and restart server"
echo " 2. Use a different port (you will be prompted to enter one)"
echo " 3. Cancel and exit"
echo
echo "1. Kill the process using TCP ${PORT} and restart"
echo "2. Start the server on a different port"
echo "3. Cancel"
read -r -p "Select option (1-3): " choice
find_pids_on_port() {
local port="$1"
local pids=""
if command -v lsof >/dev/null 2>&1; then
pids=$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
fi
if [[ -z "$pids" ]] && command -v fuser >/dev/null 2>&1; then
# fuser prints "7777/tcp: 1234 5678"
pids=$(fuser "${port}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' || true)
fi
if [[ -z "$pids" ]] && command -v ss >/dev/null 2>&1; then
pids=$(ss -lptn "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u || true)
fi
echo "$pids"
local port="$1" pids=""
if command -v lsof >/dev/null 2>&1; then pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true); fi
if [[ -z "${pids}" ]] && command -v fuser >/dev/null 2>&1; then pids=$(fuser "${port}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' || true); fi
if [[ -z "${pids}" ]] && command -v ss >/dev/null 2>&1; then pids=$(ss -lptn "sport = :${port}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u || true); fi
printf '%s\n' "${pids}"
}
# 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
else
echo "ERROR: Python is not installed or not in PATH."
exit 1
fi
case "$choice" in
case "${choice}" in
1)
echo
echo "Finding process using port ${PORT}..."
pids=$(find_pids_on_port "$PORT")
if [[ -z "$pids" ]]; then
echo "Could not determine which process is using port ${PORT}."
echo "Try:"
echo " - Restarting your computer"
echo " - Running with elevated permissions"
echo " - Or choose option 2 to use a different port"
echo
echo "Manual tip: lsof -i :${PORT} or ss -lptn 'sport = :${PORT}'"
exit 1
fi
echo "Killing process(es): $pids"
pids=$(find_pids_on_port "${PORT}")
[[ -n "${pids}" ]] || { echo "Could not determine the process using TCP ${PORT}." >&2; exit 1; }
echo "Stopping process(es): ${pids}"
# shellcheck disable=SC2086
if ! kill -9 $pids 2>/dev/null; then
echo "ERROR: Could not kill process. Try running this script with sudo."
exit 1
fi
echo "Process killed successfully."
echo
echo "Waiting for port to be released..."
kill -9 ${pids} 2>/dev/null || { echo "Could not kill the process. Try with sufficient permissions." >&2; exit 1; }
sleep 2
echo
echo "Restarting server..."
exec bash ./start.sh
;;
2)
echo
read -r -p "Enter desired port (1024-65535, default is ${PORT}): " NEW_PORT
if [[ -z "${NEW_PORT}" ]]; then
NEW_PORT=$PORT
fi
if ! [[ "$NEW_PORT" =~ ^[0-9]+$ ]]; then
echo "ERROR: Port must be a number."
exit 1
fi
if [[ "$NEW_PORT" -lt 1024 ]]; then
echo "ERROR: Port must be 1024 or higher."
exit 1
fi
if [[ "$NEW_PORT" -gt 65535 ]]; then
echo "ERROR: Port must be 65535 or lower."
exit 1
fi
echo
echo "Updating commonwealth-server.json to use port ${NEW_PORT}..."
"$PYTHON" - <<PY
import json
from pathlib import Path
path = Path("commonwealth-server.json")
if path.exists():
with path.open("r", encoding="utf-8") as f:
cfg = json.load(f)
else:
cfg = {
"host": "0.0.0.0",
"port": ${NEW_PORT},
"server_name": "Commonwealth Online Server",
"max_players": 16,
"log_verbosity": "info",
}
cfg["port"] = ${NEW_PORT}
with path.open("w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
f.write("\n")
PY
echo "Config updated. Restarting server on port ${NEW_PORT}..."
echo
exec bash ./start.sh
;;
3)
echo
echo "Cancelled."
exit 0
;;
*)
echo
echo "Invalid option."
exit 1
read -r -p "Enter desired port (1024-65535): " new_port
[[ "${new_port}" =~ ^[0-9]+$ ]] || { echo "Port must be numeric." >&2; exit 1; }
(( new_port >= 1024 && new_port <= 65535 )) || { echo "Port must be 1024-65535." >&2; exit 1; }
echo "Starting on TCP/UDP ${new_port}. This command-line override does not rewrite commonwealth-server.json."
exec bash ./start.sh --port "${new_port}"
;;
3) exit 0 ;;
*) echo "Invalid option." >&2; exit 1 ;;
esac
-391
View File
@@ -1,391 +0,0 @@
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from client_session import ClientSession
from gns_snapshot_envelope import SnapshotEnvelopeError, decode_snapshot, encode_snapshot
from gns_transport import EventType, GnsEvent, GnsServerTransport, GnsTransportError, SendResult
from packet_codec import EncodedPacket, PacketCodecError, decode_packet
from snapshot_sequence import SequenceCounter, SequenceWindow
from transport_policy import delivery_for_packet_type, is_snapshot_packet
if TYPE_CHECKING:
from server_core import FalloutTogetherServer
@dataclass(frozen=True)
class GnsAdapterStats:
connections: int
thread_running: bool
class GnsConnectionAdapter:
def __init__(self, transport: GnsServerTransport, connection_id: int) -> None:
self.transport = transport
self.connection_id = connection_id
self._closed = False
self._lock = threading.Lock()
self._snapshot_counters = {
"transform": SequenceCounter(),
"npcState": SequenceCounter(),
}
def fileno(self) -> int:
with self._lock:
return -1 if self._closed else self.connection_id
def mark_remote_closed(self) -> None:
with self._lock:
self._closed = True
def _handle_send_result(self, packet_type: str, result: SendResult) -> None:
if result is SendResult.SENT:
return
if result is SendResult.DROPPED and is_snapshot_packet(packet_type):
return
if result is SendResult.DROPPED:
raise OSError("GNS refused to queue a reliable message")
if result is SendResult.BACKPRESSURE and is_snapshot_packet(packet_type):
return
if result is SendResult.BACKPRESSURE:
raise OSError("GNS reliable send queue is under backpressure")
if result is SendResult.NOT_CONNECTED:
self.mark_remote_closed()
raise OSError("GNS connection is no longer active")
if result is SendResult.TOO_LARGE:
raise ValueError("GNS outbound packet exceeds maximum message size")
raise OSError("GNS outbound send failed")
def send_encoded(self, encoded: EncodedPacket) -> None:
with self._lock:
if self._closed:
raise OSError("GNS connection is closed")
packet_type = encoded.packet_type
expected_delivery = delivery_for_packet_type(packet_type)
if encoded.delivery is not expected_delivery:
raise OSError("GNS packet delivery metadata does not match protocol policy")
wire_payload = encoded.payload
if is_snapshot_packet(packet_type):
with self._lock:
sequence = self._snapshot_counters[packet_type].advance()
try:
wire_payload = encode_snapshot(packet_type, encoded.payload, sequence)
except SnapshotEnvelopeError as error:
raise ValueError(str(error)) from error
wire_packet = EncodedPacket(packet_type, wire_payload, encoded.delivery)
result = self.transport.send_encoded(self.connection_id, wire_packet)
self._handle_send_result(packet_type, result)
def sendall(self, framed_payload: bytes) -> None:
"""Temporary compatibility for callers that still emit TCP line framing."""
raw = bytes(framed_payload)
if not raw.endswith(b"\n") or raw.count(b"\n") != 1:
raise OSError("GNS compatibility adapter received invalid TCP framing")
payload = raw[:-1]
try:
packet = decode_packet(payload)
except PacketCodecError as error:
raise OSError(f"invalid outbound GNS packet: {error}") from error
packet_type = packet["type"]
self.send_encoded(EncodedPacket(packet_type, payload, delivery_for_packet_type(packet_type)))
def close(self) -> None:
with self._lock:
if self._closed:
return
self._closed = True
try:
self.transport.disconnect(self.connection_id, debug="Commonwealth Online disconnect")
except Exception:
pass
class GnsGameplayAdapter:
def __init__(self, server: "FalloutTogetherServer", transport: GnsServerTransport) -> None:
self.server = server
self.transport = transport
self._clients: dict[int, ClientSession] = {}
self._connected_monotonic: dict[int, float] = {}
self._incoming_snapshot_windows: dict[tuple[int, str], SequenceWindow] = {}
self._lock = threading.RLock()
self._thread: threading.Thread | None = None
self._thread_running = False
def get_stats(self) -> GnsAdapterStats:
with self._lock:
return GnsAdapterStats(len(self._clients), self._thread_running)
def _send_pre_session_end(self, connection_id: int, code: str, reason: str) -> None:
packet = self.server._build_session_ended_packet(code, reason)
self.transport.send_packet(connection_id, packet)
def _welcome_packet(self, client: ClientSession) -> dict:
from server_core import PROTOCOL_VERSION
return {
"type": "welcome",
"playerId": client.player_id,
"serverTime": time.time(),
"serverName": self.server.server_name,
"serverDescription": self.server.server_description,
"protocolVersion": PROTOCOL_VERSION,
"capabilities": [
"interest-v1",
"hello-v2",
"bounded-framing",
"rate-limit-v1",
"movement-correction-v1",
"npc-authority-epoch-v1",
"player-state-v1",
"gns-message-transport-v1",
"gns-snapshot-sequence-v1",
],
}
def _handle_connected(self, event: GnsEvent) -> None:
from server_core import SESSION_ENDED_BANNED, SESSION_ENDED_RATE_LIMITED
endpoint = self.transport.remote_endpoint(event.connection_id)
if endpoint is None:
self.transport.disconnect(event.connection_id, debug="Remote endpoint unavailable")
return
ban_entry = self.server._ban_store.get_ban(endpoint.host)
if ban_entry is not None:
with self.server._lock:
self.server._stats["bannedConnectionsRejected"] += 1
self._send_pre_session_end(event.connection_id, SESSION_ENDED_BANNED, ban_entry.reason)
self.transport.disconnect(event.connection_id, debug="Banned")
return
if not self.server._allow_connect_attempt(endpoint.host):
with self.server._lock:
self.server._stats["pendingConnectionsRejected"] += 1
self._send_pre_session_end(
event.connection_id,
SESSION_ENDED_RATE_LIMITED,
"Too many connection attempts.",
)
self.transport.disconnect(event.connection_id, debug="Connection attempt rate limited")
return
connection = GnsConnectionAdapter(self.transport, event.connection_id)
client = self.server._assign_client(connection, (endpoint.host, endpoint.port))
if client is None:
self._send_pre_session_end(
event.connection_id,
SESSION_ENDED_RATE_LIMITED,
"Too many pending connections.",
)
connection.close()
return
connected_mono = time.monotonic()
self.server._init_rate_state(client, connected_mono)
with self._lock:
self._clients[event.connection_id] = client
self._connected_monotonic[event.connection_id] = connected_mono
try:
self.server._send_packet(client, self._welcome_packet(client))
except (OSError, ValueError):
self.server._disconnect_client(client)
self._purge_closed_clients()
def _account_transport_reject(self, client: ClientSession, reason: str, *, warning: bool = False) -> None:
if not self.server._allow_packet(client):
return
client.record_received(time.time())
with self.server._lock:
self.server._stats["packetsReceived"] += 1
self.server._reject_packet(client, reason, warning=warning)
def _handle_message(self, event: GnsEvent) -> None:
client = self._client_for_connection(event.connection_id)
if client is None:
self.transport.disconnect(event.connection_id, debug="Message before GNS admission")
return
payload = event.payload
try:
envelope = decode_snapshot(payload)
except SnapshotEnvelopeError as error:
self._account_transport_reject(client, f"Malformed GNS snapshot envelope: {error}")
return
if envelope is not None:
window_key = (event.connection_id, envelope.packet_type)
with self._lock:
window = self._incoming_snapshot_windows.setdefault(window_key, SequenceWindow())
accepted = window.accept(envelope.sequence)
if not accepted:
self._account_transport_reject(client, "Stale or duplicate GNS snapshot sequence", warning=False)
return
try:
packet = decode_packet(envelope.payload)
except PacketCodecError as error:
self._account_transport_reject(client, f"Invalid GNS snapshot payload: {error}")
return
if packet["type"] != envelope.packet_type:
self._account_transport_reject(client, "GNS snapshot envelope family does not match packet type")
return
payload = envelope.payload
else:
try:
packet = decode_packet(payload)
except PacketCodecError:
packet = None
if packet is not None and is_snapshot_packet(packet["type"]):
self._account_transport_reject(client, "GNS snapshot missing required sequence envelope")
return
try:
line = payload.decode("utf-8", errors="strict")
except UnicodeDecodeError:
self._account_transport_reject(client, "Packet is not valid UTF-8")
return
self.server._handle_line(client, line)
self._purge_closed_clients()
def _handle_oversize(self, event: GnsEvent) -> None:
from server_core import SESSION_ENDED_PACKET_TOO_LARGE
client = self._client_for_connection(event.connection_id)
if client is None:
self.transport.disconnect(event.connection_id, debug="Oversized pre-session packet")
return
self.server._end_client_session(
client,
code=SESSION_ENDED_PACKET_TOO_LARGE,
reason="Packet exceeded maximum message size.",
)
self._purge_closed_clients()
def _handle_disconnected(self, event: GnsEvent) -> None:
client = self._client_for_connection(event.connection_id)
if client is None:
return
if isinstance(client.connection, GnsConnectionAdapter):
client.connection.mark_remote_closed()
self.server._disconnect_client(client)
self._remove_mapping(event.connection_id)
def _client_for_connection(self, connection_id: int) -> ClientSession | None:
with self._lock:
return self._clients.get(connection_id)
def _remove_mapping(self, connection_id: int) -> None:
with self._lock:
self._clients.pop(connection_id, None)
self._connected_monotonic.pop(connection_id, None)
stale_windows = [key for key in self._incoming_snapshot_windows if key[0] == connection_id]
for key in stale_windows:
self._incoming_snapshot_windows.pop(key, None)
def _purge_closed_clients(self) -> None:
with self._lock:
stale = [
connection_id
for connection_id, client in self._clients.items()
if client.connection.fileno() < 0
]
for connection_id in stale:
self._remove_mapping(connection_id)
def _enforce_timeouts(self) -> None:
from server_core import CLIENT_HANDSHAKE_TIMEOUT_SECONDS, CLIENT_IDLE_TIMEOUT_SECONDS
now_mono = time.monotonic()
now_wall = time.time()
with self._lock:
snapshot = [
(connection_id, client, self._connected_monotonic.get(connection_id, now_mono))
for connection_id, client in self._clients.items()
]
for connection_id, client, connected_mono in snapshot:
if not client.gameplay_active and now_mono - connected_mono > CLIENT_HANDSHAKE_TIMEOUT_SECONDS:
self.transport.disconnect(connection_id, debug="Handshake timeout")
if isinstance(client.connection, GnsConnectionAdapter):
client.connection.mark_remote_closed()
self.server._disconnect_client(client)
self._remove_mapping(connection_id)
continue
if (
client.gameplay_active
and client.last_packet_at is not None
and now_wall - client.last_packet_at > CLIENT_IDLE_TIMEOUT_SECONDS
):
self.transport.disconnect(connection_id, debug="Idle timeout")
if isinstance(client.connection, GnsConnectionAdapter):
client.connection.mark_remote_closed()
self.server._disconnect_client(client)
self._remove_mapping(connection_id)
def pump_once(self, max_events: int = 128) -> int:
processed = 0
while processed < max_events:
event = self.transport.poll()
if event is None:
break
processed += 1
if event.type is EventType.CONNECTED:
self._handle_connected(event)
elif event.type is EventType.MESSAGE:
self._handle_message(event)
elif event.type is EventType.OVERSIZE_MESSAGE:
self._handle_oversize(event)
elif event.type is EventType.DISCONNECTED:
self._handle_disconnected(event)
self._enforce_timeouts()
self._purge_closed_clients()
return processed
def _run(self) -> None:
while True:
with self._lock:
if not self._thread_running:
break
try:
processed = self.pump_once()
except GnsTransportError as error:
self.server._log(f"GNS transport loop stopped: {error}", level="error")
with self._lock:
self._thread_running = False
break
if processed == 0:
time.sleep(0.002)
def start(self) -> None:
with self._lock:
if self._thread_running:
return
self._thread_running = True
self._thread = threading.Thread(target=self._run, daemon=True, name="CommonwealthOnlineGNS")
self._thread.start()
def stop(self) -> None:
with self._lock:
self._thread_running = False
thread = self._thread
self._thread = None
clients = list(self._clients.items())
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=1.0)
for connection_id, client in clients:
if isinstance(client.connection, GnsConnectionAdapter):
client.connection.mark_remote_closed()
try:
self.transport.disconnect(connection_id, debug="GNS adapter stopped")
except Exception:
pass
self.server._disconnect_client(client)
with self._lock:
self._clients.clear()
self._connected_monotonic.clear()
self._incoming_snapshot_windows.clear()
self.transport.close()
-66
View File
@@ -1,66 +0,0 @@
from __future__ import annotations
import struct
from dataclasses import dataclass
from packet_codec import MAX_MESSAGE_BYTES
MAGIC = b"COG2"
VERSION = 1
HEADER = struct.Struct(">4sBBHI")
HEADER_SIZE = HEADER.size
_FAMILY_BY_PACKET_TYPE = {
"transform": 1,
"npcState": 2,
}
_PACKET_TYPE_BY_FAMILY = {value: key for key, value in _FAMILY_BY_PACKET_TYPE.items()}
class SnapshotEnvelopeError(ValueError):
pass
@dataclass(frozen=True)
class SnapshotEnvelope:
packet_type: str
sequence: int
payload: bytes
def is_snapshot_packet_type(packet_type: str) -> bool:
return packet_type in _FAMILY_BY_PACKET_TYPE
def encode_snapshot(packet_type: str, payload: bytes, sequence: int) -> bytes:
family = _FAMILY_BY_PACKET_TYPE.get(packet_type)
if family is None:
raise SnapshotEnvelopeError(f"packet type {packet_type!r} is not a GNS snapshot family")
if not isinstance(sequence, int) or isinstance(sequence, bool) or not 1 <= sequence <= 0xFFFFFFFF:
raise SnapshotEnvelopeError("snapshot sequence must be between 1 and 0xffffffff")
raw = bytes(payload)
if not raw:
raise SnapshotEnvelopeError("snapshot payload cannot be empty")
if HEADER_SIZE + len(raw) > MAX_MESSAGE_BYTES:
raise SnapshotEnvelopeError("snapshot envelope exceeds maximum GNS message size")
return HEADER.pack(MAGIC, VERSION, family, 0, sequence) + raw
def decode_snapshot(message: bytes | bytearray | memoryview) -> SnapshotEnvelope | None:
raw = bytes(message)
if not raw.startswith(MAGIC):
return None
if len(raw) < HEADER_SIZE:
raise SnapshotEnvelopeError("truncated GNS snapshot envelope")
magic, version, family, reserved, sequence = HEADER.unpack_from(raw)
if magic != MAGIC or version != VERSION or reserved != 0:
raise SnapshotEnvelopeError("invalid GNS snapshot envelope header")
packet_type = _PACKET_TYPE_BY_FAMILY.get(family)
if packet_type is None:
raise SnapshotEnvelopeError("unknown GNS snapshot family")
if sequence == 0:
raise SnapshotEnvelopeError("snapshot sequence zero is reserved")
payload = raw[HEADER_SIZE:]
if not payload:
raise SnapshotEnvelopeError("snapshot envelope payload is empty")
return SnapshotEnvelope(packet_type, sequence, payload)
-326
View File
@@ -1,326 +0,0 @@
from __future__ import annotations
import ctypes
import ipaddress
import os
import sys
from dataclasses import dataclass
from enum import IntEnum
from pathlib import Path
from typing import Any
from packet_codec import EncodedPacket, MAX_MESSAGE_BYTES, encode_packet
from transport_policy import Delivery
class GnsTransportError(RuntimeError):
pass
class EventType(IntEnum):
CONNECTED = 1
DISCONNECTED = 2
MESSAGE = 3
OVERSIZE_MESSAGE = 4
class SendResult(IntEnum):
ERROR = -1
SENT = 0
DROPPED = 1
BACKPRESSURE = 2
NOT_CONNECTED = 3
TOO_LARGE = 4
_NATIVE_DELIVERY = {
Delivery.UNRELIABLE_SEQUENCED: 0,
Delivery.RELIABLE_ORDERED: 1,
}
class _CEvent(ctypes.Structure):
_fields_ = [
("type", ctypes.c_uint32),
("connection_id", ctypes.c_uint32),
("reason", ctypes.c_int32),
("payload_size", ctypes.c_uint32),
("debug", ctypes.c_char * 128),
]
@dataclass(frozen=True)
class GnsEvent:
type: EventType
connection_id: int
payload: bytes = b""
reason: int = 0
debug: str = ""
@dataclass(frozen=True)
class RemoteEndpoint:
host: str
port: int
def _default_library_names() -> tuple[str, ...]:
if sys.platform == "win32":
return ("commonwealth_online_gns_bridge.dll",)
if sys.platform == "darwin":
return ("libcommonwealth_online_gns_bridge.dylib",)
return ("libcommonwealth_online_gns_bridge.so",)
def _candidate_library_paths() -> list[Path]:
candidates: list[Path] = []
configured = os.environ.get("COMMONWEALTH_ONLINE_GNS_BRIDGE", "").strip()
if configured:
candidates.append(Path(configured).expanduser())
root = Path(__file__).resolve().parent
for name in _default_library_names():
candidates.extend(
(
root / name,
root / "native_transport" / name,
root.parent / name,
)
)
return candidates
def find_native_bridge() -> Path:
for candidate in _candidate_library_paths():
if candidate.is_file():
return candidate
searched = ", ".join(str(path) for path in _candidate_library_paths())
raise GnsTransportError(
"Commonwealth Online GNS native bridge was not found. "
f"Set COMMONWEALTH_ONLINE_GNS_BRIDGE or install it beside the server. Searched: {searched}"
)
class _NativeApi:
def __init__(self, library: Any) -> None:
self.library = library
self.create = library.co_gns_server_create
self.destroy = library.co_gns_server_destroy
self.local_port = library.co_gns_server_local_port
self.connection_count = library.co_gns_server_connection_count
self.poll = library.co_gns_server_poll
self.send = library.co_gns_server_send
self.disconnect = library.co_gns_server_disconnect
self.remote_ipv4 = library.co_gns_server_remote_ipv4
self.create.argtypes = [
ctypes.c_char_p,
ctypes.c_uint16,
ctypes.POINTER(ctypes.c_void_p),
ctypes.c_char_p,
ctypes.c_size_t,
]
self.create.restype = ctypes.c_int
self.destroy.argtypes = [ctypes.c_void_p]
self.destroy.restype = None
self.local_port.argtypes = [ctypes.c_void_p]
self.local_port.restype = ctypes.c_uint16
self.connection_count.argtypes = [ctypes.c_void_p]
self.connection_count.restype = ctypes.c_uint32
self.poll.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(_CEvent),
ctypes.c_void_p,
ctypes.c_uint32,
]
self.poll.restype = ctypes.c_int
self.send.argtypes = [
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.c_uint32,
]
self.send.restype = ctypes.c_int
self.disconnect.argtypes = [
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.c_int32,
ctypes.c_char_p,
]
self.disconnect.restype = ctypes.c_int
self.remote_ipv4.argtypes = [
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(ctypes.c_uint16),
]
self.remote_ipv4.restype = ctypes.c_int
class GnsServerTransport:
def __init__(
self,
bind_host: str,
port: int,
*,
library_path: str | os.PathLike[str] | None = None,
native_library: Any | None = None,
) -> None:
if not isinstance(bind_host, str) or not bind_host:
raise ValueError("bind_host must be a non-empty string")
if not isinstance(port, int) or isinstance(port, bool) or not 0 <= port <= 65535:
raise ValueError("port must be between 0 and 65535")
if native_library is None:
path = Path(library_path).expanduser() if library_path is not None else find_native_bridge()
try:
native_library = ctypes.CDLL(str(path))
except OSError as error:
raise GnsTransportError(f"Could not load GNS native bridge at {path}: {error}") from error
self._api = _NativeApi(native_library)
self._handle = ctypes.c_void_p()
error_buffer = ctypes.create_string_buffer(512)
created = self._api.create(
bind_host.encode("utf-8"),
port,
ctypes.byref(self._handle),
error_buffer,
len(error_buffer),
)
if created != 1 or not self._handle.value:
message = error_buffer.value.decode("utf-8", errors="replace").strip()
raise GnsTransportError(message or "GNS native bridge failed to start")
self._closed = False
self._payload_buffer = ctypes.create_string_buffer(MAX_MESSAGE_BYTES)
@property
def local_port(self) -> int:
self._require_open()
return int(self._api.local_port(self._handle))
@property
def connection_count(self) -> int:
self._require_open()
return int(self._api.connection_count(self._handle))
@property
def is_closed(self) -> bool:
return self._closed
def _require_open(self) -> None:
if self._closed or not self._handle.value:
raise GnsTransportError("GNS transport is closed")
def remote_endpoint(self, connection_id: int) -> RemoteEndpoint | None:
self._require_open()
if not isinstance(connection_id, int) or isinstance(connection_id, bool) or connection_id <= 0:
raise ValueError("connection_id must be a positive integer")
ipv4 = ctypes.c_uint32()
port = ctypes.c_uint16()
found = int(
self._api.remote_ipv4(
self._handle,
connection_id,
ctypes.byref(ipv4),
ctypes.byref(port),
)
)
if found != 1:
return None
return RemoteEndpoint(str(ipaddress.IPv4Address(ipv4.value)), int(port.value))
def poll(self) -> GnsEvent | None:
self._require_open()
event = _CEvent()
result = int(
self._api.poll(
self._handle,
ctypes.byref(event),
self._payload_buffer,
MAX_MESSAGE_BYTES,
)
)
if result == 0:
return None
if result < 0:
raise GnsTransportError(f"GNS native poll failed with result {result}")
try:
event_type = EventType(event.type)
except ValueError as error:
raise GnsTransportError(f"GNS native bridge returned unknown event type {event.type}") from error
if event.payload_size > MAX_MESSAGE_BYTES and event_type is not EventType.OVERSIZE_MESSAGE:
raise GnsTransportError("GNS native bridge returned an oversized message payload")
payload = b""
if event_type is EventType.MESSAGE and event.payload_size:
payload = self._payload_buffer.raw[: event.payload_size]
debug = bytes(event.debug).split(b"\0", 1)[0].decode("utf-8", errors="replace")
return GnsEvent(
type=event_type,
connection_id=int(event.connection_id),
payload=payload,
reason=int(event.reason),
debug=debug,
)
def send_encoded(self, connection_id: int, encoded: EncodedPacket) -> SendResult:
self._require_open()
if not isinstance(connection_id, int) or isinstance(connection_id, bool) or connection_id <= 0:
raise ValueError("connection_id must be a positive integer")
payload = encoded.payload
if len(payload) > MAX_MESSAGE_BYTES:
return SendResult.TOO_LARGE
buffer = ctypes.create_string_buffer(payload, len(payload))
raw_result = int(
self._api.send(
self._handle,
connection_id,
buffer,
len(payload),
_NATIVE_DELIVERY[encoded.delivery],
)
)
try:
return SendResult(raw_result)
except ValueError as error:
raise GnsTransportError(f"GNS native send returned unknown result {raw_result}") from error
def send_packet(self, connection_id: int, packet: dict[str, Any]) -> SendResult:
return self.send_encoded(connection_id, encode_packet(packet))
def disconnect(self, connection_id: int, *, reason: int = 0, debug: str = "") -> bool:
self._require_open()
if not isinstance(connection_id, int) or isinstance(connection_id, bool) or connection_id <= 0:
raise ValueError("connection_id must be a positive integer")
if not isinstance(reason, int) or isinstance(reason, bool):
raise ValueError("reason must be an integer")
clean_debug = str(debug or "")[:127]
return bool(
self._api.disconnect(
self._handle,
connection_id,
reason,
clean_debug.encode("utf-8", errors="replace"),
)
)
def close(self) -> None:
if self._closed:
return
self._closed = True
if self._handle.value:
self._api.destroy(self._handle)
self._handle = ctypes.c_void_p()
def __enter__(self) -> "GnsServerTransport":
self._require_open()
return self
def __exit__(self, exc_type, exc, traceback) -> None:
self.close()
def __del__(self) -> None:
try:
self.close()
except Exception:
pass
-133
View File
@@ -1,133 +0,0 @@
from __future__ import annotations
import json
import socket
import threading
from typing import Any
DISCOVERY_PORT = 7778
PROTOCOL_NAME = "commonwealth-online"
DEFAULT_SERVER_NAME = "Commonwealth Online Server"
DEFAULT_MAX_PLAYERS = 16
class LanDiscoveryResponder:
"""UDP responder so LAN clients can find running Commonwealth Online servers."""
def __init__(self, server: Any, discovery_port: int = DISCOVERY_PORT) -> None:
self._server = server
self._discovery_port = discovery_port
self._socket: socket.socket | None = None
self._thread: threading.Thread | None = None
self._running = False
self._lock = threading.RLock()
def start(self) -> None:
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.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()
def stop(self) -> 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:
discovery_socket.close()
except OSError:
pass
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=1.0)
def _listen_loop(self) -> None:
while True:
with self._lock:
if not self._running:
break
discovery_socket = self._socket
if discovery_socket is None:
break
try:
data, address = discovery_socket.recvfrom(2048)
except socket.timeout:
continue
except OSError:
with self._lock:
if self._running:
break
continue
response = self._build_response(data)
if response is None:
continue
self._server._log(
f"LAN discovery probe from {address[0]}:{address[1]}"
f"replying with game port {response['port']}"
)
try:
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8")
discovery_socket.sendto(encoded, address)
except OSError:
pass
def _build_response(self, data: bytes) -> dict[str, Any] | None:
try:
packet = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
if not isinstance(packet, dict):
return None
if packet.get("type") != "discover":
return None
if packet.get("protocol") != PROTOCOL_NAME:
return None
stats = self._server.get_stats()
connected_clients = int(stats.get("connectedClients", 0))
game_port = int(stats.get("port", 7777))
server_name = str(stats.get("serverName") or DEFAULT_SERVER_NAME)
server_description = str(stats.get("serverDescription") or "")
max_players = int(stats.get("maxPlayers", DEFAULT_MAX_PLAYERS))
response: dict[str, Any] = {
"type": "discoverResponse",
"protocol": PROTOCOL_NAME,
"version": 1,
"name": server_name,
"description": server_description,
"port": game_port,
"players": connected_clients,
"maxPlayers": max_players,
}
return response
-102
View File
@@ -1,102 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable
@dataclass(frozen=True, order=True)
class ScopeKey:
cell_id: str
worldspace_id: str = ""
@dataclass(frozen=True)
class AuthorityAssignment:
scope: ScopeKey
player_id: int
epoch: int
@dataclass(frozen=True)
class AuthorityChange:
scope: ScopeKey
previous_player_id: int
player_id: int
epoch: int
class NpcAuthorityManager:
"""Server-owned NPC simulation authority keyed by exact cell/worldspace scope."""
def __init__(self) -> None:
self._assignments: dict[ScopeKey, AuthorityAssignment] = {}
self._last_epoch: dict[ScopeKey, int] = {}
def clear(self) -> None:
self._assignments.clear()
self._last_epoch.clear()
def get(self, scope: ScopeKey) -> AuthorityAssignment | None:
return self._assignments.get(scope)
def assignments(self) -> tuple[AuthorityAssignment, ...]:
return tuple(self._assignments[key] for key in sorted(self._assignments))
def authorize(self, player_id: int, scope: ScopeKey, epoch: int) -> bool:
assignment = self._assignments.get(scope)
return (
assignment is not None
and assignment.player_id == int(player_id)
and assignment.epoch == int(epoch)
)
def reconcile(self, players: Iterable[tuple[int, ScopeKey]]) -> tuple[AuthorityChange, ...]:
candidates: dict[ScopeKey, list[int]] = {}
for player_id, scope in players:
if int(player_id) <= 0:
continue
candidates.setdefault(scope, []).append(int(player_id))
desired: dict[ScopeKey, int] = {
scope: min(player_ids)
for scope, player_ids in candidates.items()
if player_ids
}
changes: list[AuthorityChange] = []
all_scopes = set(self._assignments) | set(desired)
for scope in sorted(all_scopes):
previous = self._assignments.get(scope)
previous_player_id = previous.player_id if previous is not None else 0
next_player_id = desired.get(scope, 0)
if previous_player_id == next_player_id:
continue
epoch = self._last_epoch.get(scope, 0) + 1
self._last_epoch[scope] = epoch
if next_player_id == 0:
self._assignments.pop(scope, None)
else:
self._assignments[scope] = AuthorityAssignment(scope, next_player_id, epoch)
changes.append(
AuthorityChange(
scope=scope,
previous_player_id=previous_player_id,
player_id=next_player_id,
epoch=epoch,
)
)
return tuple(changes)
def scope_from_transform(state: dict[str, Any] | None) -> ScopeKey | None:
if not isinstance(state, dict):
return None
cell_id = state.get("cellId")
worldspace_id = state.get("worldspaceId", "")
if not isinstance(cell_id, str) or not cell_id:
return None
if not isinstance(worldspace_id, str):
return None
return ScopeKey(cell_id.upper().zfill(8), worldspace_id.upper().zfill(8) if worldspace_id else "")
-59
View File
@@ -1,59 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from transport_policy import Delivery, delivery_for_packet_type
MAX_MESSAGE_BYTES = 64 * 1024
class PacketCodecError(ValueError):
pass
@dataclass(frozen=True)
class EncodedPacket:
packet_type: str
payload: bytes
delivery: Delivery
def _reject_json_constant(value: str) -> None:
raise PacketCodecError(f"non-finite JSON constant is not allowed: {value}")
def encode_packet(packet: dict[str, Any]) -> EncodedPacket:
if not isinstance(packet, dict):
raise PacketCodecError("packet must be a JSON object")
packet_type = packet.get("type")
if not isinstance(packet_type, str) or not packet_type:
raise PacketCodecError("packet type must be a non-empty string")
try:
payload = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode("utf-8")
except (TypeError, ValueError) as error:
raise PacketCodecError(f"packet is not JSON serializable: {error}") from error
if len(payload) > MAX_MESSAGE_BYTES:
raise PacketCodecError("packet exceeds maximum message size")
return EncodedPacket(packet_type, payload, delivery_for_packet_type(packet_type))
def decode_packet(payload: bytes | bytearray | memoryview) -> dict[str, Any]:
raw = bytes(payload)
if len(raw) > MAX_MESSAGE_BYTES:
raise PacketCodecError("packet exceeds maximum message size")
try:
text = raw.decode("utf-8", errors="strict")
except UnicodeDecodeError as error:
raise PacketCodecError("packet is not valid UTF-8") from error
try:
packet = json.loads(text, parse_constant=_reject_json_constant)
except (json.JSONDecodeError, PacketCodecError) as error:
raise PacketCodecError(f"invalid JSON packet: {error}") from error
if not isinstance(packet, dict):
raise PacketCodecError("packet must be a JSON object")
packet_type = packet.get("type")
if not isinstance(packet_type, str) or not packet_type:
raise PacketCodecError("packet type must be a non-empty string")
return packet
-237
View File
@@ -1,237 +0,0 @@
from __future__ import annotations
import math
from collections.abc import Callable
from typing import Any
MAX_CHARACTER_NAME_CHARS = 128
MAX_EQUIPPED_ITEMS = 32
MAX_EQUIPMENT_SLOT_CHARS = 64
MAX_HEAD_PARTS = 64
MAX_MORPHS = 128
MAX_MORPH_REGIONS = 128
MAX_FACIAL_BONE_MORPHS = 128
MAX_TINTS = 128
def _is_int(value: Any, minimum: int = 0, maximum: int = 0xFFFFFFFF) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum
def _is_finite_number(value: Any, minimum: float, maximum: float) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(float(value))
and minimum <= float(value) <= maximum
)
def _normalize_form_id(value: Any, *, allow_empty: bool = True) -> str | None:
if not isinstance(value, str):
return None
if allow_empty and value == "":
return ""
if not 1 <= len(value) <= 8:
return None
if any(character not in "0123456789abcdefABCDEF" for character in value):
return None
return value.upper().zfill(8)
def _normalize_vec3(value: Any, minimum: float, maximum: float) -> list[float] | None:
if not isinstance(value, list) or len(value) != 3:
return None
if not all(_is_finite_number(component, minimum, maximum) for component in value):
return None
return [float(component) for component in value]
def _normalize_appearance(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
clean: dict[str, Any] = {}
version = value.get("version", 4)
if not _is_int(version, 1, 1000):
return None
clean["version"] = int(version)
for name in ("raceFormId", "hairColorFormId", "facialHairColorFormId", "complexionFormId"):
if name in value:
form_id = _normalize_form_id(value[name])
if form_id is None:
return None
clean[name] = form_id
if "height" in value:
if not _is_finite_number(value["height"], 0.25, 4.0):
return None
clean["height"] = float(value["height"])
if "isFemale" in value:
if not isinstance(value["isFemale"], bool):
return None
clean["isFemale"] = value["isFemale"]
if "morphWeight" in value:
weight = value["morphWeight"]
if not isinstance(weight, dict):
return None
clean_weight: dict[str, float] = {}
for name in ("thin", "muscular", "large"):
if not _is_finite_number(weight.get(name), -100.0, 100.0):
return None
clean_weight[name] = float(weight[name])
clean["morphWeight"] = clean_weight
if "bodyTintColor" in value:
color = value["bodyTintColor"]
if not isinstance(color, dict):
return None
clean_color: dict[str, int] = {}
for name in ("r", "g", "b", "a"):
if not _is_int(color.get(name), 0, 255):
return None
clean_color[name] = int(color[name])
clean["bodyTintColor"] = clean_color
if "headParts" in value:
head_parts = value["headParts"]
if not isinstance(head_parts, list) or len(head_parts) > MAX_HEAD_PARTS:
return None
clean_head_parts: list[str] = []
for form_id in head_parts:
normalized = _normalize_form_id(form_id, allow_empty=False)
if normalized is None:
return None
clean_head_parts.append(normalized)
clean["headParts"] = clean_head_parts
if "morphs" in value:
morphs = value["morphs"]
if not isinstance(morphs, list) or len(morphs) > MAX_MORPHS:
return None
clean_morphs: list[dict[str, Any]] = []
for morph in morphs:
if not isinstance(morph, dict):
return None
morph_id = _normalize_form_id(morph.get("id"), allow_empty=False)
if morph_id is None or not _is_finite_number(morph.get("value"), -1000.0, 1000.0):
return None
clean_morphs.append({"id": morph_id, "value": float(morph["value"])})
clean["morphs"] = clean_morphs
if "morphRegions" in value:
regions = value["morphRegions"]
if not isinstance(regions, list) or len(regions) > MAX_MORPH_REGIONS:
return None
if not all(_is_finite_number(region, -1000.0, 1000.0) for region in regions):
return None
clean["morphRegions"] = [float(region) for region in regions]
if "facialBoneMorphs" in value:
morphs = value["facialBoneMorphs"]
if not isinstance(morphs, list) or len(morphs) > MAX_FACIAL_BONE_MORPHS:
return None
clean_bones: list[dict[str, Any]] = []
for morph in morphs:
if not isinstance(morph, dict):
return None
morph_id = _normalize_form_id(morph.get("id"), allow_empty=False)
position = _normalize_vec3(morph.get("position"), -10000.0, 10000.0)
rotation = _normalize_vec3(morph.get("rotation"), -10000.0, 10000.0)
scale = _normalize_vec3(morph.get("scale"), -100.0, 100.0)
if morph_id is None or position is None or rotation is None or scale is None:
return None
clean_bones.append(
{
"id": morph_id,
"position": position,
"rotation": rotation,
"scale": scale,
}
)
clean["facialBoneMorphs"] = clean_bones
if "tints" in value:
tints = value["tints"]
if not isinstance(tints, list) or len(tints) > MAX_TINTS:
return None
clean_tints: list[dict[str, Any]] = []
for tint in tints:
if not isinstance(tint, dict):
return None
if not _is_int(tint.get("id"), 0, 0xFFFF):
return None
if not _is_int(tint.get("type"), 0, 0xFFFFFFFF):
return None
if not _is_int(tint.get("value"), 0, 255):
return None
clean_tint: dict[str, Any] = {
"id": int(tint["id"]),
"type": int(tint["type"]),
"value": int(tint["value"]),
}
if "color" in tint:
color = _normalize_form_id(tint["color"], allow_empty=False)
if color is None or not _is_int(tint.get("swatch", 0), 0, 0xFFFF):
return None
clean_tint["color"] = color
clean_tint["swatch"] = int(tint.get("swatch", 0))
clean_tints.append(clean_tint)
clean["tints"] = clean_tints
return clean
def normalize_player_state_packet(
packet: dict[str, Any],
normalize_action_events: Callable[[Any], list[dict[str, Any]]],
) -> dict[str, Any] | None:
clean: dict[str, Any] = {"type": "playerState"}
has_state = False
if "equippedItems" in packet:
items = packet["equippedItems"]
if not isinstance(items, list) or len(items) > MAX_EQUIPPED_ITEMS:
return None
clean_items: list[dict[str, str]] = []
for item in items:
if not isinstance(item, dict):
return None
slot = item.get("slot")
if not isinstance(slot, str) or not 1 <= len(slot) <= MAX_EQUIPMENT_SLOT_CHARS:
return None
form_id = _normalize_form_id(item.get("formId", ""))
if form_id is None:
return None
clean_items.append({"slot": slot, "formId": form_id})
clean["equippedItems"] = clean_items
has_state = True
if "appearance" in packet:
appearance = _normalize_appearance(packet["appearance"])
if appearance is None:
return None
clean["appearance"] = appearance
has_state = True
if "actionEvents" in packet:
action_events = packet["actionEvents"]
if not isinstance(action_events, list):
return None
normalized_actions = normalize_action_events(action_events)
if len(normalized_actions) != len(action_events):
return None
clean["actionEvents"] = normalized_actions
has_state = True
if "characterName" in packet:
name = packet["characterName"]
if not isinstance(name, str) or len(name) > MAX_CHARACTER_NAME_CHARS:
return None
clean["characterName"] = name
has_state = True
return clean if has_state else None
-282
View File
@@ -1,282 +0,0 @@
from __future__ import annotations
import argparse
import json
import math
import socket
import threading
import time
from dataclasses import dataclass
from typing import Any
PROTOCOL_VERSION = 2
MAX_LINE_BYTES = 64 * 1024
DEFAULT_TIMEOUT_SECONDS = 3.0
@dataclass
class SessionInfo:
player_id: int
server_protocol_version: int
world_state_host_player_id: int | None
class ProtocolV2Client:
"""Small headless Commonwealth Online client for protocol and load testing."""
def __init__(
self,
host: str,
port: int,
*,
name: str = "synthetic-client",
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
self.host = host
self.port = int(port)
self.name = name
self.timeout_seconds = float(timeout_seconds)
self.socket: socket.socket | None = None
self.session: SessionInfo | None = None
self._recv_buffer = b""
self._send_lock = threading.Lock()
def connect(self) -> SessionInfo:
if self.socket is not None:
raise RuntimeError("Client is already connected.")
connection = socket.create_connection((self.host, self.port), timeout=self.timeout_seconds)
connection.settimeout(self.timeout_seconds)
self.socket = connection
try:
welcome = self.recv_packet()
if welcome.get("type") != "welcome":
raise RuntimeError(f"Expected welcome, received {welcome.get('type')!r}")
server_protocol = _require_positive_int(welcome.get("protocolVersion"), "welcome.protocolVersion")
if server_protocol < PROTOCOL_VERSION:
raise RuntimeError(
f"Server protocol {server_protocol} does not support client protocol {PROTOCOL_VERSION}."
)
self.send_packet(
{
"type": "hello",
"protocolVersion": PROTOCOL_VERSION,
"clientVersion": "co-server-synthetic/1",
"capabilities": ["synthetic-client-v1", "interest-v1"],
"clientName": self.name,
}
)
while True:
packet = self.recv_packet()
packet_type = packet.get("type")
if packet_type == "sessionEnded":
raise RuntimeError(
f"Session rejected: {packet.get('code', 'unknown')} {packet.get('reason', '')}".strip()
)
if packet_type != "sessionReady":
continue
player_id = _require_positive_int(packet.get("playerId"), "sessionReady.playerId")
negotiated = _require_positive_int(packet.get("protocolVersion"), "sessionReady.protocolVersion")
server_version = _require_positive_int(
packet.get("serverProtocolVersion"), "sessionReady.serverProtocolVersion"
)
host_id = packet.get("worldStateHostPlayerId")
if host_id is not None:
host_id = _require_positive_int(host_id, "sessionReady.worldStateHostPlayerId")
self.session = SessionInfo(player_id, server_version, host_id)
if negotiated != PROTOCOL_VERSION:
raise RuntimeError(f"Unexpected negotiated protocol version {negotiated}.")
return self.session
except Exception:
self.close()
raise
def close(self) -> None:
connection = self.socket
self.socket = None
self.session = None
self._recv_buffer = b""
if connection is not None:
try:
connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
connection.close()
except OSError:
pass
def __enter__(self) -> ProtocolV2Client:
self.connect()
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def send_packet(self, packet: dict[str, Any]) -> None:
connection = self.socket
if connection is None:
raise RuntimeError("Client is not connected.")
encoded = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode("utf-8") + b"\n"
if len(encoded) > MAX_LINE_BYTES + 1:
raise ValueError("Packet exceeds protocol line limit.")
with self._send_lock:
connection.sendall(encoded)
def recv_packet(self) -> dict[str, Any]:
connection = self.socket
if connection is None:
raise RuntimeError("Client is not connected.")
while b"\n" not in self._recv_buffer:
chunk = connection.recv(4096)
if not chunk:
raise ConnectionError("Server closed the connection.")
self._recv_buffer += chunk
if len(self._recv_buffer) > MAX_LINE_BYTES and b"\n" not in self._recv_buffer:
raise ValueError("Server response exceeded protocol line limit.")
line, self._recv_buffer = self._recv_buffer.split(b"\n", 1)
if len(line) > MAX_LINE_BYTES:
raise ValueError("Server response exceeded protocol line limit.")
packet = json.loads(line.decode("utf-8"), parse_constant=_reject_non_finite)
if not isinstance(packet, dict):
raise ValueError("Server response must be a JSON object.")
return packet
def send_transform(
self,
*,
x: float,
y: float,
z: float,
angle_z: float,
cell_id: int,
worldspace_id: int = 0,
movement_speed: float = 0.0,
animation_direction: float = 0.0,
is_moving: bool = False,
) -> None:
if self.session is None:
raise RuntimeError("Protocol session is not ready.")
if not all(math.isfinite(value) for value in (x, y, z, angle_z, movement_speed, animation_direction)):
raise ValueError("Transform values must be finite.")
packet: dict[str, Any] = {
"type": "transform",
"playerId": self.session.player_id,
"x": float(x),
"y": float(y),
"z": float(z),
"angleZ": float(angle_z),
"cellId": f"{int(cell_id) & 0xFFFFFFFF:08X}",
"isMoving": bool(is_moving),
"movementSpeed": float(movement_speed),
"animationDirection": float(animation_direction),
}
if worldspace_id:
packet["worldspaceId"] = f"{int(worldspace_id) & 0xFFFFFFFF:08X}"
self.send_packet(packet)
def run_load_scenario(
host: str,
port: int,
*,
client_count: int,
cell_count: int,
duration_seconds: float,
send_rate_hz: float,
) -> None:
if client_count < 1:
raise ValueError("client_count must be at least 1")
if cell_count < 1:
raise ValueError("cell_count must be at least 1")
if duration_seconds <= 0:
raise ValueError("duration_seconds must be positive")
if send_rate_hz <= 0:
raise ValueError("send_rate_hz must be positive")
clients: list[ProtocolV2Client] = []
try:
for index in range(client_count):
client = ProtocolV2Client(host, port, name=f"load-{index + 1}")
session = client.connect()
clients.append(client)
print(f"connected {client.name}: player={session.player_id} protocol={session.server_protocol_version}")
interval = 1.0 / send_rate_hz
deadline = time.monotonic() + duration_seconds
tick = 0
while time.monotonic() < deadline:
started = time.monotonic()
for index, client in enumerate(clients):
group = index % cell_count
cell_id = 0x01000000 + group + 1
x = float((group * 20000) + (index * 64) + tick)
y = float(index * 32)
client.send_transform(
x=x,
y=y,
z=0.0,
angle_z=0.0,
cell_id=cell_id,
movement_speed=send_rate_hz,
animation_direction=0.0,
is_moving=True,
)
tick += 1
remaining = interval - (time.monotonic() - started)
if remaining > 0:
time.sleep(remaining)
print(
f"completed: clients={client_count} cells={cell_count} duration={duration_seconds:.1f}s "
f"rate={send_rate_hz:.1f}Hz"
)
finally:
for client in clients:
client.close()
def _require_positive_int(value: Any, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError(f"{field} must be a positive integer.")
return value
def _reject_non_finite(value: str) -> None:
raise ValueError(f"Non-finite JSON number is not allowed: {value}")
def main() -> int:
parser = argparse.ArgumentParser(description="Commonwealth Online Protocol V2 synthetic load client")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=7777)
parser.add_argument("--clients", type=int, default=8)
parser.add_argument("--cells", type=int, default=2)
parser.add_argument("--duration", type=float, default=10.0)
parser.add_argument("--rate", type=float, default=10.0)
args = parser.parse_args()
try:
run_load_scenario(
args.host,
args.port,
client_count=args.clients,
cell_count=args.cells,
duration_seconds=args.duration,
send_rate_hz=args.rate,
)
except (OSError, RuntimeError, ValueError, ConnectionError) as error:
print(f"load test failed: {error}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-5
View File
@@ -1,5 +0,0 @@
# 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
-4
View File
@@ -1,4 +0,0 @@
# 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
-4
View File
@@ -1,4 +0,0 @@
# 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
+32 -29
View File
@@ -4,37 +4,40 @@ 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"
echo "==> Rejecting Python source/runtime dependencies"
if find .. -type f -name '*.py' -print -quit | grep -q .; then
find .. -type f -name '*.py' -print
echo "ERROR: Python source files are not allowed in CO-SERVER." >&2
exit 1
fi
if find . -maxdepth 1 -type f -name 'requirements*.txt' -print -quit | grep -q .; then
find . -maxdepth 1 -type f -name 'requirements*.txt' -print
echo "ERROR: Python requirements files are not allowed." >&2
exit 1
fi
if grep -RInE --exclude-dir=.git --exclude='*.md' --exclude='PORT_TROUBLESHOOTING.txt' '(python3?|\.venv|pip install|consumer_server_cli\.py)' ..; then
echo "ERROR: Python runtime references remain in executable/configuration files." >&2
exit 1
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 "==> Checking start.sh line endings"
if LC_ALL=C grep -n $'\r' start.sh >/dev/null 2>&1; then
echo "ERROR: start.sh contains CR/CRLF line endings" >&2
exit 1
fi
echo "==> compileall"
.venv-ci/bin/python -m compileall -q .
chmod +x start.sh fix-port.sh
test -x start.sh
bash -n start.sh
bash -n fix-port.sh
echo "==> pytest"
.venv-ci/bin/python -m pytest -q
if command -v shellcheck >/dev/null 2>&1; then
shellcheck start.sh fix-port.sh
fi
echo "All Linux compatibility checks passed."
command -v dotnet >/dev/null 2>&1 || { echo "ERROR: dotnet SDK is required" >&2; exit 1; }
dotnet --info
dotnet build CommonwealthOnline.Server.csproj -c Release --nologo
dotnet run --project tests/CommonwealthOnline.Server.Tests.csproj -c Release --no-restore
echo "All C# Linux compatibility checks passed."
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env python3
"""Minimal Commonwealth Online relay server entry point."""
from __future__ import annotations
from transport_server import TransportAwareFalloutTogetherServer
if __name__ == "__main__":
server = TransportAwareFalloutTogetherServer()
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.stop()
File diff suppressed because it is too large Load Diff
-492
View File
@@ -1,492 +0,0 @@
"""
Orchestration layer for Commonwealth Online server.
Wraps the relay server lifecycle, configuration, and admin operations
for both CLI and future GUI host applications.
"""
from __future__ import annotations
import json
import re
import threading
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Callable
from admin_server import AdminServer, DEFAULT_ADMIN_PORT
from gns_gameplay_adapter import GnsGameplayAdapter
from gns_transport import GnsServerTransport
from server_core import FalloutTogetherServer
from transport_server import TransportAwareFalloutTogetherServer
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
@dataclass
class ServerConfig:
"""Server configuration."""
host: str = "0.0.0.0"
port: int = 7777
server_name: str = "Commonwealth Online Server"
server_description: str = ""
max_players: int = 16
log_verbosity: str = "info"
admin_port: int = DEFAULT_ADMIN_PORT
bans_path: str | None = None
enable_gns_transport: bool = False
gns_bridge_path: str | None = None
@dataclass
class ClientSnapshot:
"""Snapshot of a connected client."""
player_id: int
address: str
connected_at: float
packets_sent: int
packets_received: int
label: str
@dataclass
class ServerStats:
"""Server statistics snapshot."""
is_running: bool
host: str
port: str
server_name: str
server_description: str
uptime_seconds: float
connected_clients: int
clients: list[ClientSnapshot]
packets_received: int
packets_sent: int
transform_packets_received: int
transform_packets_broadcast: int
world_state_packets_received: int
world_state_packets_broadcast: int
class ServerService:
"""
High-level server orchestration facade.
Provides lifecycle management, configuration application, and admin
operations for the underlying FalloutTogetherServer relay.
"""
def __init__(self, config: ServerConfig | None = None) -> None:
self.config = config or ServerConfig()
self._server: FalloutTogetherServer | None = None
self._admin: AdminServer | None = None
self._gns_adapter: GnsGameplayAdapter | None = 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
self._stop_event = threading.Event()
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[..., 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, *, 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:
try:
listener(message, level=level)
except TypeError:
listener(message)
except Exception:
pass
def _resolve_bans_path(self) -> str:
if self.config.bans_path:
return str(Path(self.config.bans_path))
return str(Path(__file__).resolve().parent / "bans.json")
def _create_server(self) -> FalloutTogetherServer:
return TransportAwareFalloutTogetherServer(
host=self.config.host,
port=self.config.port,
server_name=self.config.server_name,
server_description=self.config.server_description,
max_players=self.config.max_players,
bans_path=self._resolve_bans_path(),
log_verbosity=self.config.log_verbosity,
)
def _start_admin(self) -> None:
self._admin = AdminServer(
handler=self.handle_admin_request,
port=self.config.admin_port,
log=self._dispatch_log,
)
self._admin.start()
def _stop_admin(self) -> None:
if self._admin is not None:
self._admin.stop()
self._admin = None
def _start_gns(self) -> None:
if not self.config.enable_gns_transport or self._server is None:
return
if self._gns_adapter is not None:
return
transport = GnsServerTransport(
self.config.host,
self._server.port,
library_path=self.config.gns_bridge_path,
)
if transport.local_port != self._server.port:
actual_port = transport.local_port
transport.close()
raise RuntimeError(
f"GNS transport bound UDP {actual_port}, expected UDP {self._server.port}."
)
adapter = GnsGameplayAdapter(self._server, transport)
try:
adapter.start()
except Exception:
transport.close()
raise
self._gns_adapter = adapter
self._dispatch_log(
f"GameNetworkingSockets gameplay transport listening on UDP {self._server.port}."
)
def _stop_gns(self) -> None:
adapter = self._gns_adapter
self._gns_adapter = None
if adapter is not None:
adapter.stop()
def start(self) -> None:
"""Start the server in a background thread."""
if self._running:
self._dispatch_log("Server is already running.")
return
self._stop_requested = False
self._stop_event.clear()
self._server = self._create_server()
self._server.add_log_listener(self._dispatch_log)
self._running = True
self._start_admin()
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:
"""Start the server and block until shutdown."""
if self._running:
raise RuntimeError("Server is already running.")
self._stop_requested = False
self._stop_event.clear()
self._server = self._create_server()
self._server.add_log_listener(self._dispatch_log)
self._running = True
self._start_admin()
self._run_server_loop()
def _run_server_loop(self) -> None:
try:
if self._server is None:
return
self._server.start()
self._start_gns()
while not self._stop_requested and self._server.is_running():
self._stop_event.wait(0.1)
finally:
self._stop_gns()
self._stop_admin()
self._running = False
def _serve_forever(self) -> None:
"""Internal server loop for background thread."""
self._run_server_loop()
def stop(self) -> None:
"""Stop the server."""
if self._stop_requested and not self._running:
return
self._stop_requested = True
self._stop_event.set()
if not self._running and self._server is None:
self._dispatch_log("Server is not running.")
return
self._stop_admin()
self._stop_gns()
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.")
def is_running(self) -> bool:
"""Check if the server is running."""
return self._running and (self._server is not None and self._server.is_running())
def get_stats(self) -> ServerStats:
"""Get current server statistics."""
if not self._server:
return ServerStats(
is_running=False,
host=self.config.host,
port=str(self.config.port),
server_name=self.config.server_name,
server_description=self.config.server_description,
uptime_seconds=0.0,
connected_clients=0,
clients=[],
packets_received=0,
packets_sent=0,
transform_packets_received=0,
transform_packets_broadcast=0,
world_state_packets_received=0,
world_state_packets_broadcast=0,
)
core_stats = self._server.get_stats()
clients_data = self._server.get_clients()
client_snapshots = [
ClientSnapshot(
player_id=client["playerId"],
address=f"{client['address']}:{client['port']}",
connected_at=client["connectedAt"],
packets_sent=client["packetsSent"],
packets_received=client["packetsReceived"],
label=f"{client['address']}:{client['port']}",
)
for client in clients_data
]
return ServerStats(
is_running=core_stats.get("isRunning", False),
host=core_stats.get("host", self.config.host),
port=str(core_stats.get("port", self.config.port)),
server_name=core_stats.get("serverName", self.config.server_name),
server_description=core_stats.get(
"serverDescription", self.config.server_description
),
uptime_seconds=core_stats.get("uptimeSeconds", 0.0),
connected_clients=core_stats.get("connectedClients", 0),
clients=client_snapshots,
packets_received=core_stats.get("packetsReceived", 0),
packets_sent=core_stats.get("packetsSent", 0),
transform_packets_received=core_stats.get("transformPacketsReceived", 0),
transform_packets_broadcast=core_stats.get("transformPacketsBroadcast", 0),
world_state_packets_received=core_stats.get("worldStatePacketsReceived", 0),
world_state_packets_broadcast=core_stats.get("worldStatePacketsBroadcast", 0),
)
def set_server_time(self, hhmm: str) -> tuple[bool, str]:
"""
Set server time (HHmm format).
Returns (success, message).
"""
if not self._server:
return False, "Server is not running."
success = self._server.set_server_time(hhmm)
if success:
return True, f"Server time set to {hhmm}."
else:
return False, f"Invalid time format. Use HHmm (e.g., 1430 for 14:30)."
def set_server_weather(self, fw_console_arg: str) -> tuple[bool, str]:
"""
Set server weather (form ID or preset name).
Returns (success, message).
"""
if not self._server:
return False, "Server is not running."
success = self._server.set_server_weather(fw_console_arg)
if success:
return True, f"Server weather updated to {fw_console_arg}."
else:
return False, f"Invalid weather ID. Use an 8-digit hex form ID or preset name."
def kick_player(self, player_id: int, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
if not self._server:
return False, "Server is not running.", None
try:
result = self._server.kick_player(int(player_id), reason=reason)
return True, f"Kicked player {player_id}.", result
except KeyError as error:
return False, str(error), None
def ban_player(self, player_id: int, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
if not self._server:
return False, "Server is not running.", None
try:
result = self._server.ban_player(int(player_id), reason=reason)
return True, f"Banned player {player_id} (IP {result.get('ip')}).", result
except KeyError as error:
return False, str(error), None
except ValueError as error:
return False, str(error), None
def ban_ip(self, ip: str, reason: str = "") -> tuple[bool, str, dict[str, Any] | None]:
if not self._server:
return False, "Server is not running.", None
try:
result = self._server.ban_ip(ip, reason=reason)
return True, f"Banned IP {result.get('ip')}.", result
except ValueError as error:
return False, str(error), None
def unban_ip(self, ip: str) -> tuple[bool, str]:
if not self._server:
return False, "Server is not running."
removed = self._server.unban_ip(ip)
if removed:
return True, f"Unbanned IP {ip}."
return False, f"IP {ip} is not banned."
def list_bans(self) -> list[dict[str, Any]]:
if not self._server:
return []
return self._server.list_bans()
def handle_admin_request(self, request: dict[str, Any]) -> dict[str, Any]:
"""Handle one admin JSON command from the localhost control channel."""
command = str(request.get("cmd") or request.get("command") or "").strip().lower()
request_id = request.get("id")
def ok(data: dict[str, Any] | None = None, message: str = "") -> dict[str, Any]:
response: dict[str, Any] = {"ok": True}
if request_id is not None:
response["id"] = request_id
if message:
response["message"] = message
if data is not None:
response["data"] = data
return response
def fail(error: str) -> dict[str, Any]:
response: dict[str, Any] = {"ok": False, "error": error}
if request_id is not None:
response["id"] = request_id
return response
if command in ("ping",):
return ok({"pong": True})
if command in ("stats", "status"):
stats = self.get_stats()
return ok(asdict(stats))
if command in ("clients", "users"):
if not self._server:
return ok({"total_clients": 0, "clients": []})
clients = []
for client in self._server.get_clients():
address = str(client.get("address", ""))
port = client.get("port")
endpoint = f"{address}:{port}" if port is not None else address
last_transform = client.get("lastTransform")
clients.append(
{
"player_id": client.get("playerId"),
"address": endpoint,
"label": endpoint,
"connected_at": client.get("connectedAt"),
"packets_sent": client.get("packetsSent", 0),
"packets_received": client.get("packetsReceived", 0),
"last_transform": (
dict(last_transform) if isinstance(last_transform, dict) else None
),
}
)
return ok({"total_clients": len(clients), "clients": clients})
if command == "bans":
return ok({"bans": self.list_bans()})
if command == "kick":
player_id = request.get("playerId", request.get("player_id"))
if player_id is None:
return fail("kick requires playerId")
reason = str(request.get("reason", "") or "")
success, message, result = self.kick_player(int(player_id), reason=reason)
return ok(result, message) if success else fail(message)
if command == "ban":
reason = str(request.get("reason", "") or "")
player_id = request.get("playerId", request.get("player_id"))
ip = request.get("ip")
if player_id is not None:
success, message, result = self.ban_player(int(player_id), reason=reason)
return ok(result, message) if success else fail(message)
if ip:
success, message, result = self.ban_ip(str(ip), reason=reason)
return ok(result, message) if success else fail(message)
return fail("ban requires playerId or ip")
if command == "unban":
ip = request.get("ip")
if not ip:
return fail("unban requires ip")
success, message = self.unban_ip(str(ip))
return ok({"ip": str(ip)}, message) if success else fail(message)
if command == "world_time":
hhmm = request.get("hhmm") or request.get("time")
if not hhmm:
return fail("world_time requires hhmm")
success, message = self.set_server_time(str(hhmm))
return ok(message=message) if success else fail(message)
if command == "world_weather":
weather = request.get("weather") or request.get("fw")
if not weather:
return fail("world_weather requires weather")
success, message = self.set_server_weather(str(weather))
return ok(message=message) if success else fail(message)
return fail(f"Unknown admin command: {command or '(empty)'}")
def stats_to_json(self, stats: ServerStats) -> str:
"""Serialize stats to JSON."""
data = asdict(stats)
return json.dumps(data, indent=2)
def looks_like_ipv4(value: str) -> bool:
"""Return True when value looks like a dotted IPv4 address."""
if not _IPV4_RE.match(value.strip()):
return False
parts = value.strip().split(".")
return all(0 <= int(part) <= 255 for part in parts)
-60
View File
@@ -1,60 +0,0 @@
from __future__ import annotations
MAX_SEQUENCE = 0xFFFFFFFF
_HALF_RANGE = 0x80000000
def next_sequence(current: int) -> int:
if not isinstance(current, int) or isinstance(current, bool) or not 0 <= current <= MAX_SEQUENCE:
raise ValueError("snapshot sequence must be an unsigned 32-bit integer")
value = (current + 1) & MAX_SEQUENCE
return 1 if value == 0 else value
def is_newer(candidate: int, baseline: int) -> bool:
if not isinstance(candidate, int) or isinstance(candidate, bool):
return False
if not isinstance(baseline, int) or isinstance(baseline, bool):
return False
if not 0 <= candidate <= MAX_SEQUENCE or not 0 <= baseline <= MAX_SEQUENCE:
return False
if candidate == 0:
return False
if baseline == 0:
return True
delta = (candidate - baseline) & MAX_SEQUENCE
return 0 < delta < _HALF_RANGE
class SequenceCounter:
def __init__(self) -> None:
self._value = 0
@property
def current(self) -> int:
return self._value
def advance(self) -> int:
self._value = next_sequence(self._value)
return self._value
def reset(self) -> None:
self._value = 0
class SequenceWindow:
def __init__(self) -> None:
self._last_accepted = 0
@property
def last_accepted(self) -> int:
return self._last_accepted
def accept(self, sequence: int) -> bool:
if not is_newer(sequence, self._last_accepted):
return False
self._last_accepted = sequence
return True
def reset(self) -> None:
self._last_accepted = 0
-12
View File
@@ -1,12 +0,0 @@
# Server Source
This folder is for external server source code.
The first server only needs to:
- Start locally
- Accept clients
- Assign player IDs
- Receive transform packets
- Broadcast packets
- Handle disconnects
+106 -122
View File
@@ -1,142 +1,126 @@
@echo off
setlocal enabledelayedexpansion
setlocal EnableExtensions DisableDelayedExpansion
cd /d "%~dp0"
echo.
echo ================================================================================
echo Commonwealth Online Server - Start Script
echo ================================================================================
echo.
set "CONFIG=%CD%\commonwealth-server.json"
set "UPDATE=0"
set "EXTRA_ARGS="
python --version >nul 2>&1
if errorlevel 1 (
echo ERROR: Python is not installed or not in PATH.
echo Please install Python 3.9+ from https://www.python.org
pause
exit /b 1
)
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
:parse
if "%~1"=="" goto parsed
if /I "%~1"=="--update-dependencies" (
set "UPDATE_DEPENDENCIES=1"
shift
goto parse_args
set "UPDATE=1"
shift
goto parse
)
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"=="--config" goto parse_config
if /I "%~1"=="-c" goto parse_config
if /I "%~1"=="--host" goto parse_host
if /I "%~1"=="-H" goto parse_host
if /I "%~1"=="--port" goto parse_port
if /I "%~1"=="-p" goto parse_port
if /I "%~1"=="--interactive" (
set "EXTRA_ARGS=%EXTRA_ARGS% --interactive"
shift
goto parse
)
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
if /I "%~1"=="-i" (
set "EXTRA_ARGS=%EXTRA_ARGS% --interactive"
shift
goto parse
)
echo %~1| findstr /I /R "\.json$" >nul
if not errorlevel 1 (
set "CONFIG_FILE=%~f1"
shift
goto parse_args
if /I "%~x1"==".json" (
set "CONFIG=%~f1"
shift
goto parse
)
set "SERVER_ARGS=!SERVER_ARGS! %1"
echo ERROR: Unknown option or argument: %~1 1>&2
exit /b 1
:parse_config
if "%~2"=="" (
echo ERROR: %~1 requires a config path. 1>&2
exit /b 1
)
set "CONFIG=%~f2"
shift
goto parse_args
:args_done
shift
goto parse
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
)
:parse_host
if "%~2"=="" (
echo ERROR: %~1 requires a host. 1>&2
exit /b 1
)
set "EXTRA_ARGS=%EXTRA_ARGS% --host %~2"
shift
shift
goto parse
:parse_port
if "%~2"=="" (
echo ERROR: %~1 requires a port. 1>&2
exit /b 1
)
set "EXTRA_ARGS=%EXTRA_ARGS% --port %~2"
shift
shift
goto parse
:parsed
set "MODE="
set "SERVER_EXE=%CD%\CommonwealthOnline.Server.exe"
set "SERVER_DLL=%CD%\CommonwealthOnline.Server.dll"
set "SERVER_PROJECT=%CD%\CommonwealthOnline.Server.csproj"
if exist "%SERVER_EXE%" set "MODE=apphost"
if defined MODE goto resolved
where dotnet >nul 2>nul
if errorlevel 1 goto missing
if exist "%SERVER_DLL%" (
set "MODE=dll"
goto resolved
)
if exist "%SERVER_PROJECT%" (
set "MODE=project"
goto resolved
)
set "VENV_PYTHON=.venv\Scripts\python.exe"
if not exist "%VENV_PYTHON%" (
echo ERROR: Virtual environment interpreter missing.
pause
:missing
echo ERROR: CommonwealthOnline.Server is not published and the .NET 8 SDK/runtime is unavailable. 1>&2
exit /b 1
:resolved
if "%UPDATE%"=="1" (
if not exist "%SERVER_PROJECT%" (
echo ERROR: --update-dependencies requires CommonwealthOnline.Server.csproj. 1>&2
exit /b 1
)
where dotnet >nul 2>nul || (
echo ERROR: --update-dependencies requires the .NET SDK. 1>&2
exit /b 1
)
dotnet restore "%SERVER_PROJECT%" || 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 not exist "%CONFIG%" (
echo Generating default configuration: %CONFIG%
call :run config init "%CONFIG%" || exit /b 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 Starting Commonwealth Online C# server
call :run serve --config "%CONFIG%" %EXTRA_ARGS%
exit /b %ERRORLEVEL%
:run
if /I "%MODE%"=="apphost" (
"%SERVER_EXE%" %*
exit /b %ERRORLEVEL%
)
echo.
if not exist "!CONFIG_FILE!" (
echo Generating default configuration file...
"%VENV_PYTHON%" -u consumer_server_cli.py config init "!CONFIG_FILE!"
if errorlevel 1 (
echo ERROR: Failed to generate config file.
pause
exit /b 1
)
echo.
if /I "%MODE%"=="dll" (
dotnet "%SERVER_DLL%" %*
exit /b %ERRORLEVEL%
)
echo Starting Commonwealth Online Server...
echo Type help for commands. Type quit or press Ctrl+C to stop.
echo.
"%VENV_PYTHON%" -u consumer_server_cli.py serve --config "!CONFIG_FILE!" --interactive !SERVER_ARGS!
set "EXIT_CODE=!ERRORLEVEL!"
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.
echo.
choice /C YN /N /M "Would you like to fix the port issue? (Y/N): "
if !errorlevel!==1 (
echo.
call fix-port.bat
)
)
pause
exit /b !EXIT_CODE!
dotnet run --project "%SERVER_PROJECT%" -c Release --no-launch-profile -- %*
exit /b %ERRORLEVEL%
Regular → Executable
+47 -180
View File
@@ -9,204 +9,71 @@ else
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"
CONFIG_FILE="${SCRIPT_DIR}/commonwealth-server.json"
UPDATE_DEPENDENCIES=0
SERVER_ARGS=()
ARGS=("$@")
ARG_INDEX=0
while [[ ${ARG_INDEX} -lt ${#ARGS[@]} ]]; do
arg="${ARGS[${ARG_INDEX}]}"
INDEX=0
while [[ ${INDEX} -lt ${#ARGS[@]} ]]; do
arg="${ARGS[${INDEX}]}"
case "${arg}" in
--update-dependencies)
UPDATE_DEPENDENCIES=1
;;
--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
;;
INDEX=$((INDEX + 1)); [[ ${INDEX} -lt ${#ARGS[@]} ]] || { echo "ERROR: ${arg} requires a path" >&2; exit 1; }
CONFIG_FILE="${ARGS[${INDEX}]}" ;;
--config=*) CONFIG_FILE="${arg#--config=}" ;;
--host|--port|-H|-p)
SERVER_ARGS+=("${arg}"); INDEX=$((INDEX + 1)); [[ ${INDEX} -lt ${#ARGS[@]} ]] || { echo "ERROR: ${arg} requires a value" >&2; exit 1; }
SERVER_ARGS+=("${ARGS[${INDEX}]}") ;;
--host=*|--port=*|--interactive|-i) SERVER_ARGS+=("${arg}") ;;
*.json) CONFIG_FILE="${arg}" ;;
-*) echo "ERROR: Unknown option: ${arg}" >&2; exit 1 ;;
*) [[ -f "${arg}" ]] && CONFIG_FILE="${arg}" || { echo "ERROR: Unexpected argument: ${arg}" >&2; exit 1; } ;;
esac
ARG_INDEX=$((ARG_INDEX + 1))
INDEX=$((INDEX + 1))
done
echo
echo "================================================================================"
echo " Commonwealth Online Server - Start Script"
echo "================================================================================"
echo
if [[ "${CONFIG_FILE}" != /* ]]; then CONFIG_FILE="${SCRIPT_DIR}/${CONFIG_FILE}"; fi
CONFIG_FILE="$(cd -- "$(dirname -- "${CONFIG_FILE}")" && pwd)/$(basename -- "${CONFIG_FILE}")"
die() {
echo "ERROR: $*" >&2
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
resolve_server() {
local apphost="${SCRIPT_DIR}/CommonwealthOnline.Server"
local dll="${SCRIPT_DIR}/CommonwealthOnline.Server.dll"
local project="${SCRIPT_DIR}/CommonwealthOnline.Server.csproj"
if [[ -f "${apphost}" ]]; then
chmod +x "${apphost}" 2>/dev/null || true
SERVER_CMD=("${apphost}")
return 0
fi
if [[ -f "${dll}" ]] && command -v dotnet >/dev/null 2>&1; then
SERVER_CMD=(dotnet "${dll}")
return 0
fi
if [[ -f "${project}" ]] && command -v dotnet >/dev/null 2>&1; then
SERVER_CMD=(dotnet run --project "${project}" -c Release --no-launch-profile --)
return 0
fi
return 1
}
if [[ ! -f "${REQUIREMENTS_FILE}" ]]; then
die "Missing requirements file: ${REQUIREMENTS_FILE}"
if ! resolve_server; then
echo "ERROR: CommonwealthOnline.Server is not published and the .NET 8 SDK/runtime is unavailable." >&2
exit 1
fi
if [[ ! -f "${ENTRY_POINT}" ]]; then
die "Missing server entry point: ${ENTRY_POINT}"
if [[ ${UPDATE_DEPENDENCIES} -eq 1 ]]; then
command -v dotnet >/dev/null 2>&1 || { echo "ERROR: --update-dependencies requires the .NET SDK" >&2; exit 1; }
dotnet restore "${SCRIPT_DIR}/CommonwealthOnline.Server.csproj"
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
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
echo "Generating default configuration: ${CONFIG_FILE}"
"${SERVER_CMD[@]}" config init "${CONFIG_FILE}"
fi
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"
if [[ -t 0 && -t 1 ]] && [[ ! " ${SERVER_ARGS[*]} " =~ " --interactive " ]] && [[ ! " ${SERVER_ARGS[*]} " =~ " -i " ]]; then
SERVER_ARGS+=(--interactive)
fi
echo
# 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}")"
# 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[@]}"
echo "Starting Commonwealth Online C# server"
exec "${SERVER_CMD[@]}" serve --config "${CONFIG_FILE}" "${SERVER_ARGS[@]}"
-55
View File
@@ -1,55 +0,0 @@
from __future__ import annotations
import socket
from packet_codec import MAX_MESSAGE_BYTES
class TcpFramingError(ValueError):
pass
def frame_message(payload: bytes | bytearray | memoryview) -> bytes:
raw = bytes(payload)
if len(raw) > MAX_MESSAGE_BYTES:
raise TcpFramingError("TCP message exceeds maximum message size")
if b"\n" in raw or b"\r" in raw:
raise TcpFramingError("transport-neutral payload must not contain raw line delimiters")
return raw + b"\n"
def send_message(connection: socket.socket, payload: bytes | bytearray | memoryview) -> None:
connection.sendall(frame_message(payload))
class LineMessageBuffer:
def __init__(self) -> None:
self._buffer = bytearray()
@property
def buffered_bytes(self) -> int:
return len(self._buffer)
def clear(self) -> None:
self._buffer.clear()
def feed(self, chunk: bytes | bytearray | memoryview) -> list[bytes]:
raw = bytes(chunk)
if not raw:
return []
self._buffer.extend(raw)
messages: list[bytes] = []
while True:
newline = self._buffer.find(b"\n")
if newline < 0:
break
if newline > MAX_MESSAGE_BYTES:
raise TcpFramingError("TCP message exceeds maximum message size")
message = bytes(self._buffer[:newline])
del self._buffer[: newline + 1]
if message.endswith(b"\r"):
message = message[:-1]
messages.append(message)
if len(self._buffer) > MAX_MESSAGE_BYTES:
raise TcpFramingError("unterminated TCP message exceeds maximum message size")
return messages
-112
View File
@@ -1,112 +0,0 @@
from __future__ import annotations
import json
import socket
import time
import unittest
from client_session import ClientSession
from fake_player import FakePlayerClient, STATUS_CONNECTED
from server_core import FalloutTogetherServer
class CombatProtocolTests(unittest.TestCase):
def setUp(self) -> None:
self.sender_server, self.sender_peer = socket.socketpair()
self.target_server, self.target_peer = socket.socketpair()
self.server = FalloutTogetherServer()
self.sender = ClientSession(
self.sender_server,
("local", 1),
1,
time.time(),
)
self.target = ClientSession(
self.target_server,
("local", 2),
2,
time.time(),
)
self.sender.mark_gameplay_active(1)
self.target.mark_gameplay_active(1)
self.server._init_rate_state(self.sender, time.monotonic())
self.server._init_rate_state(self.target, time.monotonic())
self.server._clients = {
self.sender_server: self.sender,
self.target_server: self.target,
}
def tearDown(self) -> None:
for connection in (
self.sender_server,
self.sender_peer,
self.target_server,
self.target_peer,
):
connection.close()
def test_combat_hit_is_authoritative_and_targeted(self) -> None:
self.server._handle_line(
self.sender,
json.dumps(
{
"type": "combatHit",
"playerId": 999,
"sequence": 7,
"targetPlayerId": 2,
"damage": 12.5,
"weaponFormId": "1f4a6",
}
),
)
routed = json.loads(self.target_peer.recv(4096))
self.assertEqual(routed["playerId"], 1)
self.assertEqual(routed["targetPlayerId"], 2)
self.assertEqual(routed["weaponFormId"], "0001F4A6")
self.sender_peer.setblocking(False)
with self.assertRaises(BlockingIOError):
self.sender_peer.recv(1)
def test_invalid_damage_is_not_routed(self) -> None:
self.server._handle_line(
self.sender,
'{"type":"combatHit","sequence":8,"targetPlayerId":2,"damage":NaN}',
)
self.target_peer.setblocking(False)
with self.assertRaises(BlockingIOError):
self.target_peer.recv(1)
class FakePlayerCombatHelperTests(unittest.TestCase):
def setUp(self) -> None:
self.fake_socket, self.peer_socket = socket.socketpair()
self.fake = FakePlayerClient(1, "test")
self.fake._socket = self.fake_socket
self.fake._status = STATUS_CONNECTED
def tearDown(self) -> None:
self.fake_socket.close()
self.peer_socket.close()
def test_ranged_fire_action_helper(self) -> None:
self.fake.send_ranged_fire_action()
packet = json.loads(self.peer_socket.recv(4096))
self.assertTrue(packet["weaponDrawn"])
self.assertEqual(packet["actionEvents"][0]["type"], 3)
self.assertEqual(packet["actionEvents"][0]["eventName"], "fireSingle")
def test_combat_hit_helper(self) -> None:
self.fake.send_combat_hit(2, 9.5, 0x1F4A6)
packet = json.loads(self.peer_socket.recv(4096))
self.assertEqual(packet["type"], "combatHit")
self.assertEqual(packet["targetPlayerId"], 2)
self.assertEqual(packet["weaponFormId"], "0001F4A6")
if __name__ == "__main__":
unittest.main()
-123
View File
@@ -1,123 +0,0 @@
from __future__ import annotations
import json
import socket
import time
import unittest
from client_session import ClientSession
from server_core import FalloutTogetherServer, MAX_NPCS_PER_PACKET
class NpcProtocolTests(unittest.TestCase):
def setUp(self) -> None:
self.host_server, self.host_peer = socket.socketpair()
self.client_server, self.client_peer = socket.socketpair()
self.server = FalloutTogetherServer()
self.host = ClientSession(self.host_server, ("local", 1), 1, time.time())
self.client = ClientSession(self.client_server, ("local", 2), 2, time.time())
self.host.mark_gameplay_active(1)
self.client.mark_gameplay_active(1)
self.server._init_rate_state(self.host, time.monotonic())
self.server._init_rate_state(self.client, time.monotonic())
self.server._clients = {
self.host_server: self.host,
self.client_server: self.client,
}
self.server._world_state_host_player_id = 1
def tearDown(self) -> None:
for connection in (
self.host_server,
self.host_peer,
self.client_server,
self.client_peer,
):
connection.close()
def _npc(self, index: int) -> dict:
return {
"npcId": index,
"sourceFormId": "0001A4D7",
"x": float(index),
"y": 2.0,
"z": 3.0,
"angleZ": 0.5,
"cellId": "0000003C",
}
def test_host_npc_state_is_authoritative_and_broadcast(self) -> None:
self.server._handle_line(
self.host,
json.dumps(
{
"type": "npcState",
"playerId": 999,
"npcs": [self._npc(1)],
}
),
)
relayed = json.loads(self.client_peer.recv(4096))
self.assertEqual(relayed["playerId"], 1)
self.assertTrue(relayed["fullReplace"])
self.assertEqual(relayed["npcs"][0]["npcId"], 1)
self.assertIsNotNone(self.server._last_npc_state)
def test_non_host_npc_state_is_dropped(self) -> None:
self.server._handle_line(
self.client,
'{"type":"npcState","npcs":[]}',
)
self.host_peer.setblocking(False)
with self.assertRaises(BlockingIOError):
self.host_peer.recv(1)
def test_late_joiner_receives_cached_npc_state(self) -> None:
self.server._last_npc_state = {
"type": "npcState",
"playerId": 1,
"npcs": [],
"fullReplace": True,
"serverTime": time.time(),
}
self.server._send_existing_npc_state_to_client(self.client)
relayed = json.loads(self.client_peer.recv(4096))
self.assertEqual(relayed["type"], "npcState")
self.assertEqual(relayed["playerId"], 1)
def test_host_reassignment_clears_cached_npc_state(self) -> None:
self.server._last_npc_state = {
"type": "npcState",
"playerId": 1,
"npcs": [{"npcId": 1}],
}
self.server._clients.pop(self.host_server)
self.server._reassign_world_state_host()
self.assertEqual(self.server._world_state_host_player_id, 2)
self.assertIsNone(self.server._last_npc_state)
reassignment = json.loads(self.client_peer.recv(4096))
self.assertEqual(reassignment["worldStateHostPlayerId"], 2)
def test_oversized_npc_batch_is_rejected(self) -> None:
self.server._handle_line(
self.host,
json.dumps(
{
"type": "npcState",
"npcs": [self._npc(index + 1) for index in range(MAX_NPCS_PER_PACKET + 1)],
}
),
)
self.client_peer.setblocking(False)
with self.assertRaises(BlockingIOError):
self.client_peer.recv(1)
if __name__ == "__main__":
unittest.main()
+101 -127
View File
@@ -14,18 +14,17 @@ internal static class Program
{
await Run("transport policy", TestTransportPolicy);
await Run("packet codec", TestPacketCodec);
await Run("snapshot sequence", TestSnapshotSequence);
await Run("snapshot sequencing", TestSnapshotSequencing);
await Run("snapshot envelope", TestSnapshotEnvelope);
await Run("player state validation", TestPlayerStateValidation);
await Run("npc authority epochs", TestNpcAuthority);
await Run("npc authority epochs", TestNpcAuthorityEpochs);
await Run("interest filtering", TestInterestFiltering);
await Run("config compatibility", TestConfigCompatibility);
await Run("ban persistence", TestBanStore);
await Run("authoritative server ids and interest", TestAuthoritativeServer);
await Run("durable player state relay", TestPlayerStateRelay);
await Run("npc authority validation", TestNpcAuthorityIntegration);
await Run("combat interest routing", TestCombatInterest);
await Run("ban persistence", TestBanPersistence);
await Run("server-owned ids and interest relay", TestServerOwnedIdsAndInterest);
await Run("durable player state relay", TestDurablePlayerStateRelay);
await Run("npc authority enforcement", TestNpcAuthorityEnforcement);
await Run("combat interest enforcement", TestCombatInterest);
Console.WriteLine($"C# server tests: {_passed} passed, {_failed} failed");
return _failed == 0 ? 0 : 1;
}
@@ -51,14 +50,13 @@ internal static class Program
var encoded = PacketCodec.Encode(packet);
Equal("playerState", encoded.PacketType);
Equal(Delivery.ReliableOrdered, encoded.Delivery);
var decoded = PacketCodec.Decode(encoded.Payload);
Equal("Nomad", JsonHelpers.String(decoded["characterName"]));
Throws<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "x", ["blob"] = new string('a', ProtocolConstants.MaxMessageBytes + 1) }));
Equal("Nomad", JsonHelpers.String(PacketCodec.Decode(encoded.Payload)["characterName"]));
Throws<PacketCodecException>(() => PacketCodec.Decode("[]"u8));
Throws<PacketCodecException>(() => PacketCodec.Encode(new JsonObject { ["type"] = "oversize", ["payload"] = new string('x', ProtocolConstants.MaxMessageBytes + 1) }));
return Task.CompletedTask;
}
private static Task TestSnapshotSequence()
private static Task TestSnapshotSequencing()
{
var window = new SequenceWindow();
True(window.Accept(1));
@@ -66,28 +64,26 @@ internal static class Program
False(window.Accept(2));
False(window.Accept(3));
True(SequenceWindow.IsNewer(1, uint.MaxValue));
var counter = new SequenceCounter();
Equal(1u, counter.Advance());
False(SequenceWindow.IsNewer(uint.MaxValue, 1));
return Task.CompletedTask;
}
private static Task TestSnapshotEnvelope()
{
var payload = Encoding.UTF8.GetBytes("{\"type\":\"transform\"}");
var wire = GnsSnapshotEnvelope.Encode("transform", payload, 7);
Equal(ProtocolConstants.MaxMessageBytes >= wire.Length, true);
True(GnsSnapshotEnvelope.TryDecode(wire, out var envelope, out var error));
var encoded = GnsSnapshotEnvelope.Encode("transform", payload, 7);
True(GnsSnapshotEnvelope.TryDecode(encoded, out var envelope, out var error));
Equal<string?>(null, error);
Equal("transform", envelope.PacketType);
Equal(7u, envelope.Sequence);
SequenceEqual(payload, envelope.Payload);
True(payload.AsSpan().SequenceEqual(envelope.Payload));
False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _));
return Task.CompletedTask;
}
private static Task TestPlayerStateValidation()
{
var state = new JsonObject
var packet = new JsonObject
{
["type"] = "playerState",
["characterName"] = "Nomad",
@@ -106,28 +102,32 @@ internal static class Program
},
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" })
};
var normalized = ProtocolValidation.NormalizePlayerState(state);
var normalized = ProtocolValidation.NormalizePlayerState(packet);
NotNull(normalized);
Equal("00000123", JsonHelpers.String(((JsonObject)normalized!["appearance"]!)["hairColorFormId"]));
var bad = (JsonObject)state.DeepClone();
((JsonArray)bad["actionEvents"]!)[0]!["eventName"] = "bogus";
Equal<JsonObject?>(null, ProtocolValidation.NormalizePlayerState(bad));
var appearance = normalized!["appearance"] as JsonObject;
NotNull(appearance);
Equal("00000123", JsonHelpers.String(appearance!["hairColorFormId"]));
var invalidActions = new JsonObject
{
["type"] = "playerState",
["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "invalid" })
};
Equal<JsonObject?>(null, ProtocolValidation.NormalizePlayerState(invalidActions));
return Task.CompletedTask;
}
private static Task TestNpcAuthority()
private static Task TestNpcAuthorityEpochs()
{
var manager = new NpcAuthorityManager();
var scope = new ScopeKey("00000001", "");
var changes = manager.Reconcile(new[] { (2u, scope), (1u, scope) });
Equal(1, changes.Count);
Equal(1u, changes[0].PlayerId);
Equal(1u, changes[0].Epoch);
var first = manager.Reconcile(new[] { (2u, scope), (1u, scope) });
Equal(1u, first.Single().PlayerId);
Equal(1u, first.Single().Epoch);
True(manager.Authorize(1, scope, 1));
changes = manager.Reconcile(Array.Empty<(uint, ScopeKey)>());
Equal(2u, changes[0].Epoch);
changes = manager.Reconcile(new[] { (3u, scope) });
Equal(3u, changes[0].Epoch);
var revoked = manager.Reconcile(Array.Empty<(uint PlayerId, ScopeKey Scope)>());
Equal(2u, revoked.Single().Epoch);
var regrant = manager.Reconcile(new[] { (3u, scope) });
Equal(3u, regrant.Single().Epoch);
False(manager.Authorize(1, scope, 1));
True(manager.Authorize(3, scope, 3));
return Task.CompletedTask;
@@ -136,14 +136,10 @@ internal static class Program
private static Task TestInterestFiltering()
{
var a = Transform("00000001", "000000AA", 0, 0);
var sameCell = Transform("00000001", "000000BB", 999999, 999999);
var near = Transform("00000002", "000000AA", 1000, 1000);
var far = Transform("00000003", "000000AA", 20000, 0);
var otherWorld = Transform("00000004", "000000BB", 0, 0);
True(ProtocolValidation.StatesShareInterest(a, sameCell));
True(ProtocolValidation.StatesShareInterest(a, near));
False(ProtocolValidation.StatesShareInterest(a, far));
False(ProtocolValidation.StatesShareInterest(a, otherWorld));
True(ProtocolValidation.StatesShareInterest(a, Transform("00000001", "000000BB", 500000, 500000)));
True(ProtocolValidation.StatesShareInterest(a, Transform("00000002", "000000AA", 1000, 1000)));
False(ProtocolValidation.StatesShareInterest(a, Transform("00000003", "000000AA", 20000, 0)));
False(ProtocolValidation.StatesShareInterest(a, Transform("00000004", "000000BB", 0, 0)));
return Task.CompletedTask;
}
@@ -163,53 +159,46 @@ internal static class Program
return Task.CompletedTask;
}
private static Task TestBanStore()
private static Task TestBanPersistence()
{
var dir = TempDir();
try
{
var path = Path.Combine(dir, "bans.json");
var store = new BanStore(path);
store.Ban("127.0.0.1", "test");
var reloaded = new BanStore(path);
Equal("test", reloaded.GetBan("127.0.0.1")?.Reason);
True(reloaded.Unban("127.0.0.1"));
new BanStore(path).Ban("127.0.0.1", "test");
var loaded = new BanStore(path);
Equal("test", loaded.GetBan("127.0.0.1")?.Reason);
True(loaded.Unban("127.0.0.1"));
Equal(0, new BanStore(path).List().Count);
}
finally { Directory.Delete(dir, true); }
return Task.CompletedTask;
}
private static async Task TestAuthoritativeServer()
private static async Task TestServerOwnedIdsAndInterest()
{
var fixture = CreateServerFixture();
await using var server = fixture.Server;
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 31001);
var b = new MemoryConnection("b", 31002);
True(await server.AcceptConnectionAsync(a, CancellationToken.None));
True(await server.AcceptConnectionAsync(b, CancellationToken.None));
await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
await Send(server, b, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
await Activate(fixture.Server, a, b);
var aId = ReadyId(a); var bId = ReadyId(b);
True(aId != bId);
await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
await Send(fixture.Server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
await Send(fixture.Server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
a.Clear(); b.Clear();
await Send(server, a, Transform("00000001", "000000AA", 1, 0));
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && JsonHelpers.TryUInt32(p["playerId"], 1, uint.MaxValue, out var id) && id == aId));
await Send(fixture.Server, a, Transform("00000001", "000000AA", 1, 0));
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "transform" && PlayerId(p) == aId));
}
private static async Task TestPlayerStateRelay()
private static async Task TestDurablePlayerStateRelay()
{
var fixture = CreateServerFixture();
await using var server = fixture.Server;
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 32001); var b = new MemoryConnection("b", 32002);
await Activate(server, a, b);
await Send(server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
await Send(server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
await Activate(fixture.Server, a, b);
await Send(fixture.Server, a, Transform("00000001", "000000AA", 0, 0, "spawn"));
await Send(fixture.Server, b, Transform("00000002", "000000BB", 0, 0, "spawn"));
a.Clear(); b.Clear();
await Send(server, a, new JsonObject
await Send(fixture.Server, a, new JsonObject
{
["type"] = "playerState",
["characterName"] = "Nomad",
@@ -220,62 +209,37 @@ internal static class Program
False(relay.ContainsKey("actionEvents"));
}
private static async Task TestNpcAuthorityIntegration()
private static async Task TestNpcAuthorityEnforcement()
{
var fixture = CreateServerFixture();
await using var server = fixture.Server;
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 33001);
True(await server.AcceptConnectionAsync(a, CancellationToken.None));
await Send(server, a, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 });
await Send(server, a, Transform("00000010", "", 0, 0, "spawn"));
await Activate(fixture.Server, a);
await Send(fixture.Server, a, Transform("00000010", "", 0, 0, "spawn"));
var authority = a.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "npcAuthority");
JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch);
True(JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch));
a.Clear();
await Send(server, a, new JsonObject
await Send(fixture.Server, a, new JsonObject
{
["type"] = "npcState",
["authorityEpoch"] = epoch,
["authorityCellId"] = "00000010",
["authorityWorldspaceId"] = "",
["npcs"] = new JsonArray(new JsonObject
{
["sourceFormId"] = "00000020", ["cellId"] = "00000010", ["worldspaceId"] = "",
["x"] = 1.0, ["y"] = 2.0, ["z"] = 3.0, ["angleZ"] = 0.0
})
["type"] = "npcState", ["authorityEpoch"] = epoch, ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "",
["npcs"] = new JsonArray(new JsonObject { ["sourceFormId"] = "00000020", ["cellId"] = "00000010", ["worldspaceId"] = "", ["x"] = 1.0, ["y"] = 2.0, ["z"] = 3.0, ["angleZ"] = 0.0 })
});
var stats = server.GetCoreStats();
True(JsonHelpers.TryUInt32(stats["npcStatePacketsReceived"], 0, uint.MaxValue, out var received) && received == 1);
await Send(server, a, new JsonObject
{
["type"] = "npcState", ["authorityEpoch"] = Math.Max(1u, epoch - 1), ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "",
["npcs"] = new JsonArray()
});
stats = server.GetCoreStats();
True(JsonHelpers.TryUInt32(stats["npcAuthorityRejects"], 0, uint.MaxValue, out var rejects) && rejects >= 1);
Equal(1u, Stat(fixture.Server, "npcStatePacketsReceived"));
await Send(fixture.Server, a, new JsonObject { ["type"] = "npcState", ["authorityEpoch"] = epoch + 1, ["authorityCellId"] = "00000010", ["authorityWorldspaceId"] = "", ["npcs"] = new JsonArray() });
True(Stat(fixture.Server, "npcAuthorityRejects") >= 1);
}
private static async Task TestCombatInterest()
{
var fixture = CreateServerFixture();
await using var server = fixture.Server;
await using var fixture = new ServerFixture();
var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002);
await Activate(server, a, b);
await Activate(fixture.Server, a, b);
var bId = ReadyId(b);
await Send(server, a, Transform("00000100", "000000AA", 0, 0, "spawn"));
await Send(server, b, Transform("00000200", "000000BB", 0, 0, "spawn"));
await Send(fixture.Server, a, Transform("00000100", "000000AA", 0, 0, "spawn"));
await Send(fixture.Server, b, Transform("00000200", "000000BB", 0, 0, "spawn"));
a.Clear(); b.Clear();
await Send(server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
await Send(fixture.Server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 });
False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "combatHit"));
var stats = server.GetCoreStats();
True(JsonHelpers.TryUInt32(stats["packetsRejected"], 0, uint.MaxValue, out var rejected) && rejected >= 1);
}
private static (AuthoritativeServer Server, string Dir) CreateServerFixture()
{
var dir = TempDir();
var path = Path.Combine(dir, "commonwealth-server.json");
var options = new ServerOptions { ConfigPath = path, Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 };
return (new AuthoritativeServer(options), dir);
True(Stat(fixture.Server, "packetsRejected") >= 1);
}
private static async Task Activate(AuthoritativeServer server, params MemoryConnection[] connections)
@@ -287,15 +251,7 @@ internal static class Program
}
}
private static uint ReadyId(MemoryConnection connection)
{
var ready = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady");
True(JsonHelpers.TryUInt32(ready["playerId"], 1, uint.MaxValue, out var id));
return id;
}
private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) =>
server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None);
private static Task Send(AuthoritativeServer server, MemoryConnection connection, JsonObject packet) => server.HandleMessageAsync(connection, PacketCodec.Encode(packet).Payload, CancellationToken.None);
private static JsonObject Transform(string cell, string world, double x, double y, string movement = "normal") => new()
{
@@ -303,6 +259,16 @@ internal static class Program
["cellId"] = cell, ["worldspaceId"] = world, ["movementType"] = movement
};
private static uint ReadyId(MemoryConnection connection)
{
var packet = connection.SentPackets.Last(p => JsonHelpers.String(p["type"]) == "sessionReady");
True(JsonHelpers.TryUInt32(packet["playerId"], 1, uint.MaxValue, out var id));
return id;
}
private static uint PlayerId(JsonObject packet) => JsonHelpers.TryUInt32(packet["playerId"], 0, uint.MaxValue, out var id) ? id : 0;
private static uint Stat(AuthoritativeServer server, string name) => JsonHelpers.TryUInt32(server.GetCoreStats()[name], 0, uint.MaxValue, out var value) ? value : 0;
private static string TempDir()
{
var path = Path.Combine(Path.GetTempPath(), "co-csharp-tests-" + Guid.NewGuid().ToString("N"));
@@ -313,35 +279,43 @@ internal static class Program
private static void True(bool value) { if (!value) throw new Exception("expected true"); }
private static void False(bool value) { if (value) throw new Exception("expected false"); }
private static void NotNull(object? value) { if (value is null) throw new Exception("expected non-null"); }
private static void Equal<T>(T expected, T actual) where T : notnull { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); }
private static void SequenceEqual(byte[] expected, byte[] actual) { if (!expected.AsSpan().SequenceEqual(actual)) throw new Exception("byte sequences differ"); }
private static void Equal<T>(T expected, T actual) { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); }
private static void Throws<T>(Action action) where T : Exception { try { action(); } catch (T) { return; } throw new Exception($"expected {typeof(T).Name}"); }
private sealed class ServerFixture : IAsyncDisposable
{
public ServerFixture()
{
Directory = TempDir();
Server = new AuthoritativeServer(new ServerOptions { ConfigPath = Path.Combine(Directory, "commonwealth-server.json"), Host = "127.0.0.1", Port = 7777, AdminPort = 7779, MaxPlayers = 32 });
}
public string Directory { get; }
public AuthoritativeServer Server { get; }
public async ValueTask DisposeAsync()
{
await Server.DisposeAsync();
try { System.IO.Directory.Delete(Directory, true); } catch { }
}
}
private sealed class MemoryConnection : IGameConnection
{
private readonly object _gate = new();
private readonly List<JsonObject> _sent = new();
private int _closed;
public MemoryConnection(string name, int port)
{
ConnectionKey = "memory:" + name;
RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port);
}
public MemoryConnection(string name, int port) { ConnectionKey = "memory:" + name; RemoteEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port); }
public string ConnectionKey { get; }
public string TransportName => "memory";
public IPEndPoint RemoteEndpoint { get; }
public bool IsClosed => Volatile.Read(ref _closed) != 0;
public IReadOnlyList<JsonObject> SentPackets { get { lock (_gate) return _sent.Select(JsonHelpers.CloneObject).ToArray(); } }
public ValueTask<SendOutcome> SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default)
{
if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected);
lock (_gate) _sent.Add(PacketCodec.Decode(packet.Payload));
return ValueTask.FromResult(SendOutcome.Sent);
}
public ValueTask DisconnectAsync(int reason, string debug) { Interlocked.Exchange(ref _closed, 1); return ValueTask.CompletedTask; }
public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose");
public void Clear() { lock (_gate) _sent.Clear(); }
View File
-8
View File
@@ -1,8 +0,0 @@
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))
-109
View File
@@ -1,109 +0,0 @@
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,
enable_gns_transport=True,
gns_bridge_path="/opt/commonwealth/libcommonwealth_online_gns_bridge.so",
)
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
assert loaded.enable_gns_transport is True
assert loaded.gns_bridge_path == "/opt/commonwealth/libcommonwealth_online_gns_bridge.so"
def test_gns_boolean_config_parsing_does_not_treat_false_string_as_true() -> None:
assert Config.from_dict({"enable_gns_transport": "false"}).enable_gns_transport is False
assert Config.from_dict({"enable_gns_transport": "off"}).enable_gns_transport is False
assert Config.from_dict({"enable_gns_transport": "0"}).enable_gns_transport is False
assert Config.from_dict({"enable_gns_transport": "true"}).enable_gns_transport is True
assert Config.from_dict({"enable_gns_transport": "yes"}).enable_gns_transport is True
assert Config.from_dict({"enable_gns_transport": 1}).enable_gns_transport is True
assert Config.from_dict({"enable_gns_transport": 0}).enable_gns_transport is False
with pytest.raises(ValueError, match="enable_gns_transport"):
Config.from_dict({"enable_gns_transport": "sometimes"})
def test_gns_bridge_path_normalizes_blank_to_none_and_rejects_nonstring() -> None:
assert Config.from_dict({"gns_bridge_path": ""}).gns_bridge_path is None
assert Config.from_dict({"gns_bridge_path": " "}).gns_bridge_path is None
assert Config.from_dict({"gns_bridge_path": None}).gns_bridge_path is None
with pytest.raises(ValueError, match="gns_bridge_path"):
Config.from_dict({"gns_bridge_path": 123})
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_gns_requires_explicit_ipv4_bind_host() -> None:
explicit = Config(host="127.0.0.1", enable_gns_transport=True)
ok, errors = validate_config(explicit)
assert ok, errors
wildcard = Config(host="0.0.0.0", enable_gns_transport=True)
ok, errors = validate_config(wildcard)
assert ok, errors
hostname = Config(host="localhost", enable_gns_transport=True)
ok, errors = validate_config(hostname)
assert not ok
assert any("explicit IPv4 bind address" 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))
@@ -1,27 +0,0 @@
from pathlib import Path
from config import Config
from dedicated_server import build_service_config
def test_dedicated_server_preserves_gns_rollout_fields(tmp_path: Path):
config_path = tmp_path / "commonwealth-server.json"
config = Config(
host="127.0.0.1",
port=7777,
server_name="Test",
server_description="GNS",
max_players=12,
log_verbosity="debug",
admin_port=7779,
enable_gns_transport=True,
gns_bridge_path="/opt/commonwealth/libcommonwealth_online_gns_bridge.so",
)
service_config = build_service_config(config, config_path)
assert service_config.host == "127.0.0.1"
assert service_config.port == 7777
assert service_config.max_players == 12
assert service_config.enable_gns_transport is True
assert service_config.gns_bridge_path == "/opt/commonwealth/libcommonwealth_online_gns_bridge.so"
assert service_config.bans_path == str(tmp_path / "bans.json")
-281
View File
@@ -1,281 +0,0 @@
from __future__ import annotations
import json
from collections import deque
import pytest
from gns_gameplay_adapter import GnsConnectionAdapter, GnsGameplayAdapter
from gns_snapshot_envelope import decode_snapshot, encode_snapshot
from gns_transport import EventType, GnsEvent, RemoteEndpoint, SendResult
from packet_codec import encode_packet
from server_core import PROTOCOL_VERSION
from transport_policy import Delivery
from transport_server import TransportAwareFalloutTogetherServer
class FakeGnsTransport:
def __init__(self):
self.events = deque()
self.endpoints = {}
self.sent = []
self.send_results = deque()
self.disconnected = []
self.incoming_sequences = {}
self.closed = False
def poll(self):
return self.events.popleft() if self.events else None
def remote_endpoint(self, connection_id):
return self.endpoints.get(connection_id)
def send_encoded(self, connection_id, encoded):
self.sent.append((connection_id, encoded))
return self.send_results.popleft() if self.send_results else SendResult.SENT
def send_packet(self, connection_id, packet):
return self.send_encoded(connection_id, encode_packet(packet))
def disconnect(self, connection_id, *, reason=0, debug=""):
self.disconnected.append((connection_id, reason, debug))
return True
def close(self):
self.closed = True
def decoded_sent_packet(encoded):
envelope = decode_snapshot(encoded.payload)
payload = envelope.payload if envelope is not None else encoded.payload
return json.loads(payload)
def packet_types_sent(transport: FakeGnsTransport, connection_id: int) -> list[str]:
return [
decoded_sent_packet(encoded)["type"]
for target, encoded in transport.sent
if target == connection_id
]
def queue_connect(transport: FakeGnsTransport, connection_id: int, port: int = 50000):
transport.endpoints[connection_id] = RemoteEndpoint("127.0.0.1", port)
transport.events.append(GnsEvent(EventType.CONNECTED, connection_id))
def queue_packet(
transport: FakeGnsTransport,
connection_id: int,
packet: dict,
*,
sequence: int | None = None,
raw_snapshot: bool = False,
):
payload = json.dumps(packet, separators=(",", ":")).encode()
packet_type = packet.get("type")
if packet_type in {"transform", "npcState"} and not raw_snapshot:
key = (connection_id, packet_type)
if sequence is None:
sequence = transport.incoming_sequences.get(key, 0) + 1
transport.incoming_sequences[key] = sequence
payload = encode_snapshot(packet_type, payload, sequence)
transport.events.append(GnsEvent(EventType.MESSAGE, connection_id, payload))
def complete_handshake(adapter: GnsGameplayAdapter, transport: FakeGnsTransport, connection_id: int):
queue_connect(transport, connection_id, 50000 + connection_id)
assert adapter.pump_once() == 1
assert "welcome" in packet_types_sent(transport, connection_id)
queue_packet(transport, connection_id, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
assert adapter.pump_once() == 1
assert "sessionReady" in packet_types_sent(transport, connection_id)
def transform_packet(x: float) -> dict:
return {
"type": "transform",
"x": x,
"y": 0.0,
"z": 0.0,
"angleZ": 0.0,
"cellId": "00000010",
"worldspaceId": "0000003C",
"movementType": "normal",
}
def test_gns_adapter_reuses_v2_admission_and_server_owned_identity():
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
transport = FakeGnsTransport()
adapter = GnsGameplayAdapter(server, transport)
complete_handshake(adapter, transport, 101)
client = adapter._client_for_connection(101)
assert client is not None
assert client.gameplay_active
assert client.protocol_version == PROTOCOL_VERSION
packet = transform_packet(1.0)
packet["playerId"] = 999999
queue_packet(transport, 101, packet)
adapter.pump_once()
assert client.last_transform is not None
assert client.last_transform["playerId"] == client.player_id
assert client.last_transform["playerId"] != 999999
def test_gns_adapter_preserves_reliable_and_snapshot_delivery_policy():
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
transport = FakeGnsTransport()
adapter = GnsGameplayAdapter(server, transport)
complete_handshake(adapter, transport, 201)
complete_handshake(adapter, transport, 202)
transport.sent.clear()
queue_packet(transport, 202, transform_packet(10.0))
adapter.pump_once()
queue_packet(transport, 201, transform_packet(0.0))
adapter.pump_once()
transform_relays = [
encoded
for target, encoded in transport.sent
if target == 202 and decoded_sent_packet(encoded).get("type") == "transform"
]
assert transform_relays
transform_relay = transform_relays[-1]
assert transform_relay.delivery is Delivery.UNRELIABLE_SEQUENCED
transform_envelope = decode_snapshot(transform_relay.payload)
assert transform_envelope is not None
assert transform_envelope.packet_type == "transform"
assert transform_envelope.sequence > 0
queue_packet(
transport,
201,
{"type": "playerState", "characterName": "Nomad", "actionEvents": []},
)
adapter.pump_once()
player_state_relays = [
encoded
for target, encoded in transport.sent
if target == 202 and decoded_sent_packet(encoded).get("type") == "playerState"
]
assert player_state_relays
player_state_relay = player_state_relays[-1]
assert player_state_relay.delivery is Delivery.RELIABLE_ORDERED
assert decode_snapshot(player_state_relay.payload) is None
def test_gns_snapshot_reordering_and_duplicates_never_roll_state_backward():
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
transport = FakeGnsTransport()
adapter = GnsGameplayAdapter(server, transport)
complete_handshake(adapter, transport, 501)
client = adapter._client_for_connection(501)
assert client is not None
queue_packet(transport, 501, transform_packet(1.0), sequence=1)
adapter.pump_once()
assert client.last_transform is not None
assert client.last_transform["x"] == 1.0
queue_packet(transport, 501, transform_packet(3.0), sequence=3)
adapter.pump_once()
assert client.last_transform["x"] == 3.0
rejected_before = server.get_stats()["packetsRejected"]
queue_packet(transport, 501, transform_packet(2.0), sequence=2)
adapter.pump_once()
assert client.last_transform["x"] == 3.0
assert server.get_stats()["packetsRejected"] == rejected_before + 1
queue_packet(transport, 501, transform_packet(9.0), sequence=3)
adapter.pump_once()
assert client.last_transform["x"] == 3.0
assert server.get_stats()["packetsRejected"] == rejected_before + 2
def test_gns_raw_unsequenced_snapshot_is_rejected_before_state_mutation():
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
transport = FakeGnsTransport()
adapter = GnsGameplayAdapter(server, transport)
complete_handshake(adapter, transport, 601)
client = adapter._client_for_connection(601)
assert client is not None
assert client.last_transform is None
rejected_before = server.get_stats()["packetsRejected"]
queue_packet(transport, 601, transform_packet(1.0), raw_snapshot=True)
adapter.pump_once()
assert client.last_transform is None
assert server.get_stats()["packetsRejected"] == rejected_before + 1
def test_connection_adapter_may_drop_snapshots_but_never_reliable_messages_silently():
transport = FakeGnsTransport()
connection = GnsConnectionAdapter(transport, 250)
transport.send_results.append(SendResult.DROPPED)
connection.send_encoded(encode_packet({"type": "transform", "x": 1}))
transform_wire = transport.sent[-1][1]
assert decode_snapshot(transform_wire.payload) is not None
transport.send_results.append(SendResult.BACKPRESSURE)
connection.send_encoded(encode_packet({"type": "npcState", "npcs": []}))
npc_wire = transport.sent[-1][1]
assert decode_snapshot(npc_wire.payload) is not None
transport.send_results.append(SendResult.DROPPED)
with pytest.raises(OSError):
connection.send_encoded(encode_packet({"type": "playerState", "characterName": "Nomad"}))
transport.send_results.append(SendResult.BACKPRESSURE)
with pytest.raises(OSError):
connection.send_encoded(encode_packet({"type": "combatHit", "sequence": 1}))
def test_gns_adapter_enforces_existing_ban_policy(tmp_path):
server = TransportAwareFalloutTogetherServer(
host="127.0.0.1",
port=0,
bans_path=str(tmp_path / "bans.json"),
)
server.ban_ip("127.0.0.1", "test ban")
transport = FakeGnsTransport()
adapter = GnsGameplayAdapter(server, transport)
queue_connect(transport, 301)
adapter.pump_once()
assert adapter._client_for_connection(301) is None
assert transport.disconnected
session_ended = [
decoded_sent_packet(encoded)
for target, encoded in transport.sent
if target == 301 and decoded_sent_packet(encoded).get("type") == "sessionEnded"
]
assert session_ended
assert session_ended[-1]["code"] == "banned"
assert server.get_stats()["bannedConnectionsRejected"] == 1
def test_gns_adapter_ends_session_on_native_oversize_event():
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
transport = FakeGnsTransport()
adapter = GnsGameplayAdapter(server, transport)
complete_handshake(adapter, transport, 401)
transport.events.append(
GnsEvent(EventType.OVERSIZE_MESSAGE, 401, reason=0, debug="too large")
)
adapter.pump_once()
assert adapter._client_for_connection(401) is None
assert transport.disconnected
ended_packets = [
decoded_sent_packet(encoded)
for target, encoded in transport.sent
if target == 401 and decoded_sent_packet(encoded).get("type") == "sessionEnded"
]
assert ended_packets
assert ended_packets[-1]["code"] == "packet_too_large"
@@ -1,64 +0,0 @@
import pytest
from gns_snapshot_envelope import (
HEADER_SIZE,
MAGIC,
SnapshotEnvelopeError,
decode_snapshot,
encode_snapshot,
)
from packet_codec import MAX_MESSAGE_BYTES
def test_transform_and_npc_snapshots_round_trip_with_sequence():
transform = encode_snapshot("transform", b'{"type":"transform","x":1}', 7)
decoded_transform = decode_snapshot(transform)
assert decoded_transform is not None
assert decoded_transform.packet_type == "transform"
assert decoded_transform.sequence == 7
assert decoded_transform.payload == b'{"type":"transform","x":1}'
npc = encode_snapshot("npcState", b'{"type":"npcState","npcs":[]}', 9)
decoded_npc = decode_snapshot(npc)
assert decoded_npc is not None
assert decoded_npc.packet_type == "npcState"
assert decoded_npc.sequence == 9
def test_reliable_json_is_not_misidentified_as_snapshot_envelope():
assert decode_snapshot(b'{"type":"playerState"}') is None
def test_snapshot_envelope_rejects_invalid_family_sequence_and_size():
with pytest.raises(SnapshotEnvelopeError):
encode_snapshot("playerState", b"{}", 1)
with pytest.raises(SnapshotEnvelopeError):
encode_snapshot("transform", b"{}", 0)
with pytest.raises(SnapshotEnvelopeError):
encode_snapshot("transform", b"x" * MAX_MESSAGE_BYTES, 1)
def test_snapshot_decoder_rejects_truncated_or_corrupt_headers():
with pytest.raises(SnapshotEnvelopeError):
decode_snapshot(MAGIC)
valid = bytearray(encode_snapshot("transform", b"{}", 1))
valid[4] = 99
with pytest.raises(SnapshotEnvelopeError):
decode_snapshot(valid)
valid = bytearray(encode_snapshot("transform", b"{}", 1))
valid[5] = 99
with pytest.raises(SnapshotEnvelopeError):
decode_snapshot(valid)
valid = bytearray(encode_snapshot("transform", b"{}", 1))
valid[6:8] = b"\x00\x01"
with pytest.raises(SnapshotEnvelopeError):
decode_snapshot(valid)
def test_snapshot_header_leaves_payload_under_native_64k_cap():
payload = b"x" * (MAX_MESSAGE_BYTES - HEADER_SIZE)
message = encode_snapshot("transform", payload, 0xFFFFFFFF)
assert len(message) == MAX_MESSAGE_BYTES
-150
View File
@@ -1,150 +0,0 @@
from __future__ import annotations
import ctypes
from collections import deque
import pytest
from gns_transport import EventType, GnsServerTransport, GnsTransportError, RemoteEndpoint, SendResult
class FakeFunction:
def __init__(self, callback):
self.callback = callback
self.argtypes = None
self.restype = None
def __call__(self, *args):
return self.callback(*args)
class FakeNativeBridge:
def __init__(self):
self.events = deque()
self.sent = []
self.disconnects = []
self.endpoints = {42: (0x7F000001, 54321)}
self.destroyed = False
self.co_gns_server_create = FakeFunction(self._create)
self.co_gns_server_destroy = FakeFunction(self._destroy)
self.co_gns_server_local_port = FakeFunction(lambda handle: 7777)
self.co_gns_server_connection_count = FakeFunction(lambda handle: 2)
self.co_gns_server_poll = FakeFunction(self._poll)
self.co_gns_server_send = FakeFunction(self._send)
self.co_gns_server_disconnect = FakeFunction(self._disconnect)
self.co_gns_server_remote_ipv4 = FakeFunction(self._remote_ipv4)
def _create(self, host, port, out_handle, error_buffer, error_size):
assert host == b"127.0.0.1"
assert port == 0
ctypes.cast(out_handle, ctypes.POINTER(ctypes.c_void_p))[0] = ctypes.c_void_p(0x1234)
error_buffer.value = b""
return 1
def _destroy(self, handle):
self.destroyed = True
def _poll(self, handle, event_pointer, payload_buffer, payload_capacity):
if not self.events:
return 0
event_type, connection_id, payload, reason, debug = self.events.popleft()
event = event_pointer._obj
event.type = event_type
event.connection_id = connection_id
event.reason = reason
event.payload_size = len(payload)
event.debug = debug.encode()
if event_type == EventType.MESSAGE and payload:
assert len(payload) <= payload_capacity
ctypes.memmove(payload_buffer, payload, len(payload))
return 1
def _send(self, handle, connection_id, payload_buffer, payload_size, delivery):
payload = ctypes.string_at(payload_buffer, payload_size)
self.sent.append((connection_id, payload, delivery))
return SendResult.SENT
def _disconnect(self, handle, connection_id, reason, debug):
self.disconnects.append((connection_id, reason, debug))
return 1
def _remote_ipv4(self, handle, connection_id, out_ipv4, out_port):
endpoint = self.endpoints.get(connection_id)
if endpoint is None:
return 0
ipv4, port = endpoint
ctypes.cast(out_ipv4, ctypes.POINTER(ctypes.c_uint32))[0] = ipv4
ctypes.cast(out_port, ctypes.POINTER(ctypes.c_uint16))[0] = port
return 1
def test_wrapper_polls_message_events_without_line_framing():
native = FakeNativeBridge()
native.events.append((EventType.CONNECTED, 42, b"", 0, ""))
native.events.append((EventType.MESSAGE, 42, b'{"type":"transform","x":1}', 0, ""))
transport = GnsServerTransport("127.0.0.1", 0, native_library=native)
assert transport.local_port == 7777
assert transport.connection_count == 2
assert transport.remote_endpoint(42) == RemoteEndpoint("127.0.0.1", 54321)
assert transport.remote_endpoint(99) is None
connected = transport.poll()
assert connected is not None
assert connected.type is EventType.CONNECTED
assert connected.connection_id == 42
message = transport.poll()
assert message is not None
assert message.type is EventType.MESSAGE
assert message.payload == b'{"type":"transform","x":1}'
assert not message.payload.endswith(b"\n")
assert transport.poll() is None
def test_wrapper_maps_protocol_delivery_to_native_send_modes():
native = FakeNativeBridge()
transport = GnsServerTransport("127.0.0.1", 0, native_library=native)
assert transport.send_packet(42, {"type": "transform", "x": 1}) is SendResult.SENT
assert transport.send_packet(42, {"type": "playerState", "characterName": "Nomad"}) is SendResult.SENT
assert native.sent[0][0] == 42
assert native.sent[0][2] == 0
assert native.sent[0][1] == b'{"type":"transform","x":1}'
assert native.sent[1][2] == 1
assert native.sent[1][1] == b'{"type":"playerState","characterName":"Nomad"}'
def test_wrapper_preserves_disconnect_and_oversize_events():
native = FakeNativeBridge()
native.events.append((EventType.DISCONNECTED, 9, b"", 5003, "peer timeout"))
native.events.append((EventType.OVERSIZE_MESSAGE, 10, b"x" * (64 * 1024 + 1), 0, "too large"))
transport = GnsServerTransport("127.0.0.1", 0, native_library=native)
disconnected = transport.poll()
assert disconnected is not None
assert disconnected.type is EventType.DISCONNECTED
assert disconnected.connection_id == 9
assert disconnected.reason == 5003
assert disconnected.debug == "peer timeout"
oversize = transport.poll()
assert oversize is not None
assert oversize.type is EventType.OVERSIZE_MESSAGE
assert oversize.connection_id == 10
assert oversize.payload == b""
def test_wrapper_disconnect_and_close_are_idempotent():
native = FakeNativeBridge()
transport = GnsServerTransport("127.0.0.1", 0, native_library=native)
assert transport.disconnect(77, reason=1000, debug="test")
assert native.disconnects == [(77, 1000, b"test")]
transport.close()
transport.close()
assert native.destroyed
assert transport.is_closed
with pytest.raises(GnsTransportError):
transport.poll()
-404
View File
@@ -1,404 +0,0 @@
from __future__ import annotations
import json
import socket
import time
import pytest
from server_core import PROTOCOL_VERSION, FalloutTogetherServer, states_share_interest
_RECV_BUFFERS: dict[socket.socket, bytes] = {}
def recv_packet(sock: socket.socket, timeout: float = 2.0) -> dict:
sock.settimeout(timeout)
data = _RECV_BUFFERS.get(sock, b"")
while b"\n" not in data:
chunk = sock.recv(4096)
if not chunk:
raise ConnectionError("socket closed before a complete packet was received")
data += chunk
line, remainder = data.split(b"\n", 1)
_RECV_BUFFERS[sock] = remainder
return json.loads(line.decode("utf-8"))
def recv_until(sock: socket.socket, packet_type: str, timeout: float = 2.0) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
remaining = max(0.01, deadline - time.monotonic())
try:
packet = recv_packet(sock, timeout=remaining)
except socket.timeout:
break
if packet.get("type") == packet_type:
return packet
raise AssertionError(f"did not receive packet type {packet_type}")
def recv_authority(
sock: socket.socket,
cell: str,
world: str = "0000003C",
timeout: float = 2.0,
) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
remaining = max(0.01, deadline - time.monotonic())
packet = recv_until(sock, "npcAuthority", timeout=remaining)
if packet.get("authorityCellId") == cell and packet.get("authorityWorldspaceId", "") == world:
return packet
raise AssertionError(f"did not receive npcAuthority for {cell}/{world}")
def assert_no_packet_type(sock: socket.socket, packet_type: str, timeout: float = 0.2) -> None:
with pytest.raises(AssertionError):
recv_until(sock, packet_type, timeout=timeout)
def send_packet(sock: socket.socket, packet: dict) -> None:
sock.sendall(json.dumps(packet, separators=(",", ":")).encode() + b"\n")
def connect(server: FalloutTogetherServer) -> tuple[socket.socket, dict]:
sock = socket.create_connection(("127.0.0.1", server.port), timeout=2.0)
return sock, recv_packet(sock)
def hello(sock: socket.socket) -> dict:
send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
return recv_until(sock, "sessionReady")
def transform(
cell: str,
x: float,
y: float,
world: str = "0000003C",
movement_type: str = "normal",
) -> dict:
return {
"type": "transform",
"x": x,
"y": y,
"z": 0.0,
"angleZ": 0.0,
"cellId": cell,
"worldspaceId": world,
"movementType": movement_type,
}
def npc_state(
cell: str,
epoch: int,
x: float,
world: str = "0000003C",
source_form_id: str = "000000AA",
) -> dict:
return {
"type": "npcState",
"authorityEpoch": epoch,
"authorityCellId": cell,
"authorityWorldspaceId": world,
"npcs": [
{
"npcId": 1,
"baseFormId": "0000000F",
"sourceFormId": source_form_id,
"x": x,
"y": 0.0,
"z": 0.0,
"angleZ": 0.0,
"cellId": cell,
"worldspaceId": world,
"isDead": False,
}
],
}
def wait_for_stat(server: FalloutTogetherServer, key: str, minimum: int, timeout: float = 2.0) -> int:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = int(server.get_stats()[key])
if value >= minimum:
return value
time.sleep(0.01)
raise AssertionError(f"stat {key} did not reach {minimum}")
def start_server(max_players: int = 16) -> FalloutTogetherServer:
server = FalloutTogetherServer(host="127.0.0.1", port=0, max_players=max_players)
server.start()
deadline = time.time() + 2
while not server.is_running() and time.time() < deadline:
time.sleep(0.01)
return server
def test_interest_exact_cell_and_exterior_radius():
a = transform("00000001", 0, 0)
same = transform("00000001", 50000, 50000)
near = transform("00000002", 1000, 1000)
far = transform("00000002", 20000, 20000)
other_world = transform("00000002", 1000, 1000, world="0000003D")
assert states_share_interest(a, same)
assert states_share_interest(a, near)
assert not states_share_interest(a, far)
assert not states_share_interest(a, other_world)
def test_recv_packet_preserves_coalesced_lines():
reader, writer = socket.socketpair()
try:
writer.sendall(b'{"type":"first"}\n{"type":"second"}\n')
assert recv_packet(reader)["type"] == "first"
assert recv_packet(reader)["type"] == "second"
finally:
_RECV_BUFFERS.pop(reader, None)
reader.close()
writer.close()
def test_idle_transport_does_not_take_world_authority():
server = start_server()
idle, _ = connect(server)
active, active_welcome = connect(server)
ready = hello(active)
assert ready["playerId"] == active_welcome["playerId"]
stats = server.get_stats()
assert stats["connectedClients"] == 1
assert stats["pendingConnections"] == 1
assert server._world_state_host_player_id == active_welcome["playerId"]
idle.close()
active.close()
server.stop()
def test_max_players_applies_to_activated_sessions_not_probes():
server = start_server(max_players=1)
first, _ = connect(server)
hello(first)
second, _ = connect(server)
send_packet(second, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
ended = recv_until(second, "sessionEnded")
assert ended["code"] == "server_full"
first.close()
second.close()
server.stop()
def test_protocol_mismatch_is_rejected():
server = start_server()
sock, _ = connect(server)
send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION + 1})
ended = recv_until(sock, "sessionEnded")
assert ended["code"] == "protocol_mismatch"
sock.close()
server.stop()
def test_nan_transform_is_rejected_and_not_cached():
server = start_server()
sock, welcome = connect(server)
hello(sock)
sock.sendall(
b'{"type":"transform","x":NaN,"y":0,"z":0,"angleZ":0,"cellId":"00000001"}\n'
)
time.sleep(0.05)
client = server._find_client_by_player_id(welcome["playerId"])
assert client is not None
assert client.last_transform is None
assert server.get_stats()["packetsRejected"] >= 1
sock.close()
server.stop()
def test_transform_interest_filters_distant_peer():
server = start_server()
a, _ = connect(server)
hello(a)
b, _ = connect(server)
hello(b)
send_packet(b, transform("00000020", 25000, 25000))
time.sleep(0.05)
send_packet(a, transform("00000010", 0, 0))
assert_no_packet_type(b, "transform")
assert server.get_stats()["transformPacketsInterestFiltered"] >= 1
a.close()
b.close()
server.stop()
def test_scoped_npc_authorities_publish_independently():
server = start_server()
a, a_welcome = connect(server)
hello(a)
b, b_welcome = connect(server)
hello(b)
send_packet(a, transform("00000010", 0.0, 0.0))
authority_a = recv_authority(a, "00000010")
assert authority_a["authorityPlayerId"] == a_welcome["playerId"]
send_packet(b, transform("00000020", 25000.0, 0.0))
authority_b = recv_authority(b, "00000020")
assert authority_b["authorityPlayerId"] == b_welcome["playerId"]
send_packet(a, npc_state("00000010", authority_a["authorityEpoch"], 0.0))
send_packet(b, npc_state("00000020", authority_b["authorityEpoch"], 25000.0, source_form_id="000000AB"))
assert wait_for_stat(server, "npcStatePacketsReceived", 2) == 2
assert server.get_stats()["npcAuthorityRejects"] == 0
a.close()
b.close()
server.stop()
def test_stale_npc_authority_epoch_cannot_publish_after_handoff():
server = start_server()
a, a_welcome = connect(server)
hello(a)
b, b_welcome = connect(server)
hello(b)
send_packet(a, transform("00000010", 0.0, 0.0))
initial_a = recv_authority(a, "00000010")
initial_b = recv_authority(b, "00000010")
assert initial_a["authorityPlayerId"] == a_welcome["playerId"]
assert initial_b["authorityEpoch"] == initial_a["authorityEpoch"]
send_packet(b, transform("00000010", 64.0, 0.0))
current_b = recv_authority(b, "00000010")
assert current_b["authorityPlayerId"] == a_welcome["playerId"]
assert current_b["authorityEpoch"] == initial_a["authorityEpoch"]
send_packet(a, npc_state("00000010", initial_a["authorityEpoch"], 0.0))
wait_for_stat(server, "npcStatePacketsReceived", 1)
send_packet(a, transform("00000020", 0.0, 0.0, movement_type="cell_change"))
handoff = recv_authority(b, "00000010")
assert handoff["authorityPlayerId"] == b_welcome["playerId"]
assert handoff["authorityEpoch"] > initial_a["authorityEpoch"]
send_packet(a, npc_state("00000010", initial_a["authorityEpoch"], 0.0))
wait_for_stat(server, "npcAuthorityRejects", 1)
assert server.get_stats()["npcStatePacketsReceived"] == 1
send_packet(b, npc_state("00000010", handoff["authorityEpoch"], 64.0, source_form_id="000000AB"))
wait_for_stat(server, "npcStatePacketsReceived", 2)
a.close()
b.close()
server.stop()
def test_scoped_npc_packet_rejects_entry_outside_authority_scope():
server = start_server()
sock, _ = connect(server)
hello(sock)
send_packet(sock, transform("00000010", 0.0, 0.0))
authority = recv_authority(sock, "00000010")
packet = npc_state("00000010", authority["authorityEpoch"], 0.0)
packet["npcs"][0]["cellId"] = "00000011"
send_packet(sock, packet)
wait_for_stat(server, "npcAuthorityRejects", 1)
assert server.get_stats()["npcStatePacketsReceived"] == 0
sock.close()
server.stop()
def test_rejected_normal_teleport_is_corrected_and_never_relayed():
server = start_server()
a, a_welcome = connect(server)
hello(a)
b, _ = connect(server)
hello(b)
send_packet(b, transform("00000010", 64.0, 0.0))
recv_until(a, "transform")
send_packet(a, transform("00000010", 0.0, 0.0))
first_relay = recv_until(b, "transform")
assert first_relay["x"] == 0.0
send_packet(a, transform("00000010", 100000.0, 0.0))
correction = recv_until(a, "positionCorrection")
assert correction["x"] == 0.0
assert correction["cellId"] == "00000010"
assert_no_packet_type(b, "transform")
client = server._find_client_by_player_id(a_welcome["playerId"])
assert client is not None
assert client.last_transform is not None
assert client.last_transform["x"] == 0.0
stats = server.get_stats()
assert stats["movementPacketsRejected"] >= 1
assert stats["movementCorrectionsSent"] >= 1
a.close()
b.close()
server.stop()
def test_scope_change_requires_explicit_transition():
server = start_server()
sock, welcome = connect(server)
hello(sock)
send_packet(sock, transform("00000010", 0.0, 0.0))
time.sleep(0.05)
send_packet(sock, transform("00000011", 10.0, 0.0))
correction = recv_until(sock, "positionCorrection")
assert correction["cellId"] == "00000010"
client = server._find_client_by_player_id(welcome["playerId"])
assert client is not None
assert client.last_transform is not None
assert client.last_transform["cellId"] == "00000010"
send_packet(sock, transform("00000011", 10.0, 0.0, movement_type="cell_change"))
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline:
if client.last_transform is not None and client.last_transform.get("cellId") == "00000011":
break
time.sleep(0.01)
assert client.last_transform is not None
assert client.last_transform["cellId"] == "00000011"
sock.close()
server.stop()
def test_zero_elapsed_burst_cannot_bypass_movement_envelope():
server = start_server()
sock, welcome = connect(server)
hello(sock)
send_packet(sock, transform("00000010", 0.0, 0.0))
send_packet(sock, transform("00000010", 50000.0, 0.0))
correction = recv_until(sock, "positionCorrection")
assert correction["x"] == 0.0
client = server._find_client_by_player_id(welcome["playerId"])
assert client is not None
assert client.last_transform is not None
assert client.last_transform["x"] == 0.0
sock.close()
server.stop()
def test_oversized_unterminated_packet_closes_session():
server = start_server()
sock, _ = connect(server)
sock.sendall(b"x" * (64 * 1024 + 1))
ended = recv_until(sock, "sessionEnded")
assert ended["code"] == "packet_too_large"
sock.close()
server.stop()
-141
View File
@@ -1,141 +0,0 @@
from __future__ import annotations
import json
import socket
import time
import server_core
from server_core import PROTOCOL_VERSION, FalloutTogetherServer
_RECV_BUFFERS: dict[socket.socket, bytes] = {}
def recv_packet(sock: socket.socket, timeout: float = 2.0) -> dict:
sock.settimeout(timeout)
data = _RECV_BUFFERS.get(sock, b"")
while b"\n" not in data:
chunk = sock.recv(4096)
if not chunk:
raise ConnectionError("socket closed before a complete packet was received")
data += chunk
line, remainder = data.split(b"\n", 1)
_RECV_BUFFERS[sock] = remainder
return json.loads(line.decode("utf-8"))
def recv_until(sock: socket.socket, predicate, timeout: float = 2.0) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
packet = recv_packet(sock, timeout=max(0.01, deadline - time.monotonic()))
except socket.timeout:
break
if predicate(packet):
return packet
raise AssertionError("did not receive matching packet")
def send_packet(sock: socket.socket, packet: dict) -> None:
sock.sendall(json.dumps(packet, separators=(",", ":")).encode() + b"\n")
def connect_v2(server: FalloutTogetherServer) -> tuple[socket.socket, int]:
sock = socket.create_connection(("127.0.0.1", server.port), timeout=2.0)
welcome = recv_until(sock, lambda packet: packet.get("type") == "welcome")
send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
ready = recv_until(sock, lambda packet: packet.get("type") == "sessionReady")
assert ready["playerId"] == welcome["playerId"]
return sock, int(welcome["playerId"])
def transform(cell: str, x: float, y: float = 0.0) -> dict:
return {
"type": "transform",
"x": x,
"y": y,
"z": 0.0,
"angleZ": 0.0,
"cellId": cell,
"worldspaceId": "0000003C",
"movementType": "normal",
}
def start_server() -> FalloutTogetherServer:
server = FalloutTogetherServer(host="127.0.0.1", port=0, max_players=24)
server.start()
deadline = time.monotonic() + 2.0
while not server.is_running() and time.monotonic() < deadline:
time.sleep(0.01)
return server
def test_sixteen_clients_do_not_cross_broadcast_distant_cell_transforms(monkeypatch):
# All synthetic clients originate from localhost. Bypass only the per-IP
# connection-attempt throttle so this test measures gameplay capacity and
# interest filtering instead of the independent abuse-control policy.
monkeypatch.setattr(server_core, "MAX_CONNECT_ATTEMPTS", 64)
server = start_server()
clients: list[tuple[socket.socket, int]] = []
try:
clients = [connect_v2(server) for _ in range(16)]
assert server.get_stats()["connectedClients"] == 16
near = clients[:8]
far = clients[8:]
for index, (sock, _player_id) in enumerate(near):
send_packet(sock, transform("00000010", float(index * 32)))
for index, (sock, _player_id) in enumerate(far):
send_packet(sock, transform("00000020", 30000.0 + float(index * 32)))
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
sessions = [server._find_client_by_player_id(player_id) for _sock, player_id in clients]
if all(session is not None and session.last_transform is not None for session in sessions):
break
time.sleep(0.01)
assert all(
session is not None and session.last_transform is not None
for session in (server._find_client_by_player_id(player_id) for _sock, player_id in clients)
)
sender_sock, sender_id = near[0]
near_peer_sock, _near_peer_id = near[1]
far_peer_sock, _far_peer_id = far[0]
marker_x = 128.0
send_packet(sender_sock, transform("00000010", marker_x))
relayed = recv_until(
near_peer_sock,
lambda packet: packet.get("type") == "transform"
and packet.get("playerId") == sender_id
and packet.get("x") == marker_x,
)
assert relayed["cellId"] == "00000010"
try:
recv_until(
far_peer_sock,
lambda packet: packet.get("type") == "transform"
and packet.get("playerId") == sender_id
and packet.get("x") == marker_x,
timeout=0.4,
)
except AssertionError:
pass
else:
raise AssertionError("distant cell received a transform that should have been interest-filtered")
stats = server.get_stats()
assert stats["connectedClients"] == 16
assert stats["transformPacketsInterestFiltered"] >= len(far)
finally:
for sock, _player_id in clients:
_RECV_BUFFERS.pop(sock, None)
try:
sock.close()
except OSError:
pass
server.stop()
-60
View File
@@ -1,60 +0,0 @@
from __future__ import annotations
from npc_authority import NpcAuthorityManager, ScopeKey, scope_from_transform
def test_first_player_in_scope_becomes_authority() -> None:
manager = NpcAuthorityManager()
scope = ScopeKey("00000010", "0000003C")
changes = manager.reconcile([(7, scope), (4, scope)])
assert len(changes) == 1
assignment = manager.get(scope)
assert assignment is not None
assert assignment.player_id == 4
assert assignment.epoch == 1
assert manager.authorize(4, scope, 1)
assert not manager.authorize(7, scope, 1)
def test_disconnect_reassigns_and_increments_epoch() -> None:
manager = NpcAuthorityManager()
scope = ScopeKey("00000010", "0000003C")
manager.reconcile([(1, scope), (2, scope)])
changes = manager.reconcile([(2, scope)])
assert len(changes) == 1
assert changes[0].previous_player_id == 1
assert changes[0].player_id == 2
assert changes[0].epoch == 2
assert not manager.authorize(1, scope, 1)
assert manager.authorize(2, scope, 2)
def test_two_scopes_have_independent_authorities() -> None:
manager = NpcAuthorityManager()
a = ScopeKey("00000010", "0000003C")
b = ScopeKey("00000020", "0000003C")
manager.reconcile([(5, a), (2, a), (7, b), (9, b)])
assert manager.get(a).player_id == 2
assert manager.get(b).player_id == 7
assert manager.get(a).epoch == 1
assert manager.get(b).epoch == 1
def test_empty_scope_revokes_and_future_reentry_uses_new_epoch() -> None:
manager = NpcAuthorityManager()
scope = ScopeKey("00000010", "")
manager.reconcile([(1, scope)])
revoked = manager.reconcile([])
assert revoked[0].player_id == 0
assert revoked[0].epoch == 2
assert manager.get(scope) is None
granted = manager.reconcile([(3, scope)])
assert granted[0].player_id == 3
assert granted[0].epoch == 3
def test_scope_from_transform_normalizes_form_ids() -> None:
scope = scope_from_transform({"cellId": "1a", "worldspaceId": "3c"})
assert scope == ScopeKey("0000001A", "0000003C")
assert scope_from_transform(None) is None
assert scope_from_transform({"cellId": ""}) is None
-48
View File
@@ -1,48 +0,0 @@
import json
import pytest
from packet_codec import MAX_MESSAGE_BYTES, PacketCodecError, decode_packet, encode_packet
from transport_policy import Delivery
def test_encoded_packet_has_no_tcp_line_framing():
encoded = encode_packet({"type": "transform", "x": 1.0})
assert encoded.packet_type == "transform"
assert encoded.delivery is Delivery.UNRELIABLE_SEQUENCED
assert encoded.payload == b'{"type":"transform","x":1.0}'
assert not encoded.payload.endswith(b"\n")
def test_reliable_packet_carries_delivery_policy_separately_from_json():
encoded = encode_packet({"type": "playerState", "characterName": "Nomad"})
assert encoded.delivery is Delivery.RELIABLE_ORDERED
assert json.loads(encoded.payload) == {"type": "playerState", "characterName": "Nomad"}
def test_decode_packet_accepts_one_complete_message_without_delimiter():
assert decode_packet(b'{"type":"sessionReady","playerId":7}') == {
"type": "sessionReady",
"playerId": 7,
}
def test_codec_rejects_nonfinite_invalid_utf8_nonobject_and_missing_type():
with pytest.raises(PacketCodecError):
encode_packet({"type": "transform", "x": float("nan")})
with pytest.raises(PacketCodecError):
decode_packet(b'{"type":"transform","x":NaN}')
with pytest.raises(PacketCodecError):
decode_packet(b"\xff")
with pytest.raises(PacketCodecError):
decode_packet(b"[]")
with pytest.raises(PacketCodecError):
decode_packet(b"{}")
def test_codec_enforces_64k_message_limit_before_transport():
payload = "x" * MAX_MESSAGE_BYTES
with pytest.raises(PacketCodecError):
encode_packet({"type": "playerState", "characterName": payload})
with pytest.raises(PacketCodecError):
decode_packet(b"x" * (MAX_MESSAGE_BYTES + 1))
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
from player_state import normalize_player_state_packet
from server_core import _normalize_action_events
def valid_packet() -> dict:
return {
"type": "playerState",
"playerId": 9999,
"characterName": "Sole Survivor",
"equippedItems": [
{"slot": "rightHand", "formId": "ff"},
{"slot": "body", "formId": ""},
],
"appearance": {
"version": 4,
"raceFormId": "13746",
"height": 1.0,
"morphWeight": {"thin": 0.2, "muscular": 0.3, "large": 0.5},
"bodyTintColor": {"r": 1, "g": 2, "b": 3, "a": 255},
"hairColorFormId": "",
"facialHairColorFormId": "",
"complexionFormId": "",
"isFemale": False,
"headParts": ["1a2b"],
"morphs": [{"id": "10", "value": 0.5}],
"morphRegions": [0.25],
"facialBoneMorphs": [
{
"id": "20",
"position": [0.0, 1.0, 2.0],
"rotation": [3.0, 4.0, 5.0],
"scale": [1.0, 1.0, 1.0],
}
],
"tints": [{"id": 1, "type": 2, "value": 3, "color": "ff00ff00", "swatch": 4}],
},
"actionEvents": [{"sequence": 7, "type": 3, "eventName": "fireSingle"}],
}
def test_player_state_normalizes_only_reliable_fields():
clean = normalize_player_state_packet(valid_packet(), _normalize_action_events)
assert clean is not None
assert clean["type"] == "playerState"
assert "playerId" not in clean
assert clean["equippedItems"][0]["formId"] == "000000FF"
assert clean["appearance"]["raceFormId"] == "00013746"
assert clean["appearance"]["headParts"] == ["00001A2B"]
assert clean["appearance"]["tints"][0]["color"] == "FF00FF00"
assert clean["actionEvents"][0]["sequence"] == 7
def test_empty_player_state_is_rejected():
assert normalize_player_state_packet({"type": "playerState"}, _normalize_action_events) is None
def test_invalid_equipment_is_rejected():
packet = valid_packet()
packet["equippedItems"][0]["formId"] = "not-a-form"
assert normalize_player_state_packet(packet, _normalize_action_events) is None
def test_invalid_appearance_numbers_are_rejected():
packet = valid_packet()
packet["appearance"]["height"] = float("nan")
assert normalize_player_state_packet(packet, _normalize_action_events) is None
def test_invalid_action_event_is_rejected_instead_of_silently_dropped():
packet = valid_packet()
packet["actionEvents"] = [{"sequence": 7, "type": 3, "eventName": "notAllowed"}]
assert normalize_player_state_packet(packet, _normalize_action_events) is None
def test_character_name_is_bounded():
packet = valid_packet()
packet["characterName"] = "x" * 129
assert normalize_player_state_packet(packet, _normalize_action_events) is None
@@ -1,244 +0,0 @@
from __future__ import annotations
import json
import socket
import time
import pytest
from server_core import PROTOCOL_VERSION, FalloutTogetherServer
_RECV_BUFFERS: dict[socket.socket, bytes] = {}
def recv_packet(sock: socket.socket, timeout: float = 2.0) -> dict:
sock.settimeout(timeout)
data = _RECV_BUFFERS.get(sock, b"")
while b"\n" not in data:
chunk = sock.recv(4096)
if not chunk:
raise ConnectionError("socket closed before a complete packet was received")
data += chunk
line, remainder = data.split(b"\n", 1)
_RECV_BUFFERS[sock] = remainder
return json.loads(line.decode("utf-8"))
def recv_until(sock: socket.socket, packet_type: str, timeout: float = 2.0) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
remaining = max(0.01, deadline - time.monotonic())
try:
packet = recv_packet(sock, timeout=remaining)
except socket.timeout:
break
if packet.get("type") == packet_type:
return packet
raise AssertionError(f"did not receive packet type {packet_type}")
def send_packet(sock: socket.socket, packet: dict) -> None:
sock.sendall(json.dumps(packet, separators=(",", ":")).encode() + b"\n")
def start_server() -> FalloutTogetherServer:
server = FalloutTogetherServer(host="127.0.0.1", port=0)
server.start()
deadline = time.monotonic() + 2.0
while not server.is_running() and time.monotonic() < deadline:
time.sleep(0.01)
return server
def connect_v2(server: FalloutTogetherServer) -> tuple[socket.socket, dict]:
sock = socket.create_connection(("127.0.0.1", server.port), timeout=2.0)
welcome = recv_until(sock, "welcome")
send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION})
ready = recv_until(sock, "sessionReady")
assert ready["playerId"] == welcome["playerId"]
return sock, welcome
def player_state(**overrides) -> dict:
packet = {
"type": "playerState",
"playerId": 999999,
"characterName": "Sole Survivor",
"equippedItems": [{"slot": "rightHand", "formId": "ff"}],
"appearance": {
"version": 4,
"raceFormId": "13746",
"height": 1.0,
"headParts": ["1a2b"],
},
"actionEvents": [{"sequence": 3, "type": 3, "eventName": "fireSingle"}],
}
packet.update(overrides)
return packet
def transform(**overrides) -> dict:
packet = {
"type": "transform",
"x": 0.0,
"y": 0.0,
"z": 0.0,
"angleZ": 0.0,
"cellId": "00000001",
"worldspaceId": "0000003C",
"movementType": "normal",
}
packet.update(overrides)
return packet
def wait_for_stat(server: FalloutTogetherServer, key: str, minimum: int, timeout: float = 2.0) -> int:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = int(server.get_stats()[key])
if value >= minimum:
return value
time.sleep(0.01)
raise AssertionError(f"stat {key} did not reach {minimum}")
def establish_transforms(
server: FalloutTogetherServer,
sender: socket.socket,
receiver: socket.socket,
*,
same_interest: bool,
) -> None:
send_packet(sender, transform())
receiver_transform = transform(
cellId="00000001" if same_interest else "00000002",
worldspaceId="0000003C" if same_interest else "00000099",
)
send_packet(receiver, receiver_transform)
wait_for_stat(server, "transformPacketsReceived", 2)
def test_player_state_relay_uses_server_owned_identity():
server = start_server()
sender, sender_welcome = connect_v2(server)
receiver, _ = connect_v2(server)
try:
establish_transforms(server, sender, receiver, same_interest=True)
send_packet(sender, player_state())
relayed = recv_until(receiver, "playerState")
assert relayed["playerId"] == sender_welcome["playerId"]
assert relayed["characterName"] == "Sole Survivor"
assert relayed["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}]
assert relayed["appearance"]["raceFormId"] == "00013746"
assert relayed["actionEvents"][0]["sequence"] == 3
wait_for_stat(server, "playerStatePacketsReceived", 1)
wait_for_stat(server, "playerStatePacketsBroadcast", 1)
finally:
sender.close()
receiver.close()
server.stop()
def test_v2_transform_strips_reliable_player_state_fields():
server = start_server()
sender, _ = connect_v2(server)
receiver, _ = connect_v2(server)
try:
send_packet(
sender,
transform(
equippedItems=[{"slot": "rightHand", "formId": "000000FF"}],
appearance={"version": 4, "raceFormId": "00013746"},
actionEvents=[{"sequence": 3, "type": 3, "eventName": "fireSingle"}],
characterName="must-not-ride-transform",
),
)
relayed = recv_until(receiver, "transform")
assert "equippedItems" not in relayed
assert "appearance" not in relayed
assert "actionEvents" not in relayed
assert "characterName" not in relayed
finally:
sender.close()
receiver.close()
server.stop()
def test_out_of_interest_player_state_strips_action_events_but_keeps_durable_state():
server = start_server()
sender, _ = connect_v2(server)
receiver, _ = connect_v2(server)
try:
establish_transforms(server, sender, receiver, same_interest=False)
send_packet(sender, player_state())
relayed = recv_until(receiver, "playerState")
assert relayed["characterName"] == "Sole Survivor"
assert relayed["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}]
assert "actionEvents" not in relayed
finally:
sender.close()
receiver.close()
server.stop()
def test_out_of_interest_action_only_player_state_is_not_relayed():
server = start_server()
sender, _ = connect_v2(server)
receiver, _ = connect_v2(server)
try:
establish_transforms(server, sender, receiver, same_interest=False)
send_packet(
sender,
{
"type": "playerState",
"actionEvents": [{"sequence": 4, "type": 3, "eventName": "fireSingle"}],
},
)
wait_for_stat(server, "playerStatePacketsReceived", 1)
with pytest.raises(AssertionError):
recv_until(receiver, "playerState", timeout=0.2)
finally:
sender.close()
receiver.close()
server.stop()
def test_new_v2_client_receives_cached_player_state_without_stale_action_events():
server = start_server()
sender, sender_welcome = connect_v2(server)
receiver = None
try:
send_packet(sender, transform())
wait_for_stat(server, "transformPacketsReceived", 1)
send_packet(sender, player_state())
wait_for_stat(server, "playerStatePacketsReceived", 1)
receiver, _ = connect_v2(server)
cached = recv_until(receiver, "playerState")
assert cached["playerId"] == sender_welcome["playerId"]
assert cached["characterName"] == "Sole Survivor"
assert cached["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}]
assert "actionEvents" not in cached
finally:
sender.close()
if receiver is not None:
receiver.close()
server.stop()
def test_malformed_player_state_is_rejected_before_relay():
server = start_server()
sender, _ = connect_v2(server)
receiver, _ = connect_v2(server)
try:
before = server.get_stats()["packetsRejected"]
send_packet(sender, player_state(equippedItems=[{"slot": "rightHand", "formId": "not-a-form"}]))
wait_for_stat(server, "packetsRejected", before + 1)
assert server.get_stats()["playerStatePacketsReceived"] == 0
with pytest.raises(AssertionError):
recv_until(receiver, "playerState", timeout=0.2)
finally:
sender.close()
receiver.close()
server.stop()
-88
View File
@@ -1,88 +0,0 @@
from __future__ import annotations
import socket
import threading
import time
from protocol_v2_client import PROTOCOL_VERSION, ProtocolV2Client
from server_core import FalloutTogetherServer
def _start_server(max_players: int = 4) -> tuple[FalloutTogetherServer, threading.Thread]:
server = FalloutTogetherServer(host="127.0.0.1", port=0, max_players=max_players)
server._prepare_server_socket()
thread = threading.Thread(target=server._accept_loop, daemon=True)
thread.start()
return server, thread
def _stop_server(server: FalloutTogetherServer, thread: threading.Thread) -> None:
server.stop()
thread.join(timeout=3.0)
def _recv_until(client: ProtocolV2Client, packet_type: str, timeout: float = 2.0) -> dict:
assert client.socket is not None
previous_timeout = client.socket.gettimeout()
client.socket.settimeout(0.25)
deadline = time.monotonic() + timeout
try:
while time.monotonic() < deadline:
try:
packet = client.recv_packet()
except socket.timeout:
continue
if packet.get("type") == packet_type:
return packet
finally:
client.socket.settimeout(previous_timeout)
raise AssertionError(f"did not receive packet type {packet_type}")
def test_synthetic_client_uses_protocol_v2_without_legacy_activation() -> None:
server, thread = _start_server()
client = ProtocolV2Client("127.0.0.1", server.port, name="pytest-v2")
try:
session = client.connect()
assert session.player_id == 1
assert session.server_protocol_version == PROTOCOL_VERSION
stats = server.get_stats()
assert stats["connectedClients"] == 1
assert stats["protocolV2Connections"] == 1
assert stats["legacyConnections"] == 0
assert stats["pendingConnections"] == 0
finally:
client.close()
_stop_server(server, thread)
def test_same_cell_v2_clients_receive_each_others_transforms() -> None:
server, thread = _start_server()
first = ProtocolV2Client("127.0.0.1", server.port, name="first")
second = ProtocolV2Client("127.0.0.1", server.port, name="second")
try:
first.connect()
second.connect()
# The first client receives a host-assignment control packet after sessionReady.
_recv_until(first, "worldStateHost")
second.send_transform(
x=100.0,
y=200.0,
z=300.0,
angle_z=0.5,
cell_id=0x0000003C,
movement_speed=150.0,
animation_direction=90.0,
is_moving=True,
)
relayed = _recv_until(first, "transform")
assert relayed["playerId"] == second.session.player_id
assert relayed["cellId"] == "0000003C"
assert relayed["animationDirection"] == 90.0
finally:
first.close()
second.close()
_stop_server(server, thread)
-200
View File
@@ -1,200 +0,0 @@
from __future__ import annotations
import json
import socket
import threading
import time
from pathlib import Path
import pytest
from admin_server import DEFAULT_ADMIN_TOKEN_PATH, 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_admin_rejects_unauthenticated_requests_and_does_not_disclose_token(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()
with socket.create_connection(("127.0.0.1", admin_port), timeout=2.0) as conn:
conn.sendall(b'{"cmd":"ping"}\n')
raw = conn.recv(4096)
unauthorized = json.loads(raw.split(b"\n", 1)[0].decode("utf-8"))
assert unauthorized.get("ok") is False
assert unauthorized.get("error") == "Unauthorized admin request."
authenticated = send_admin_command({"cmd": "status"}, port=admin_port)
assert authenticated.get("ok") is True
token = DEFAULT_ADMIN_TOKEN_PATH.read_text(encoding="utf-8").strip()
assert token
assert token not in json.dumps(authenticated, sort_keys=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
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
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()
-114
View File
@@ -1,114 +0,0 @@
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
@@ -1,120 +0,0 @@
from __future__ import annotations
import pytest
import server_service
from server_service import ServerConfig, ServerService
from transport_server import TransportAwareFalloutTogetherServer
class FakeCoreServer:
def __init__(self, port: int = 7777):
self.port = port
class FakeTransport:
instances = []
local_port_override = None
def __init__(self, host, port, *, library_path=None):
self.host = host
self.requested_port = port
self.library_path = library_path
self.local_port = self.local_port_override or port
self.closed = False
self.__class__.instances.append(self)
def close(self):
self.closed = True
class FakeAdapter:
instances = []
def __init__(self, core, transport):
self.core = core
self.transport = transport
self.started = False
self.stopped = False
self.__class__.instances.append(self)
def start(self):
self.started = True
def stop(self):
self.stopped = True
self.transport.close()
def reset_fakes():
FakeTransport.instances.clear()
FakeTransport.local_port_override = None
FakeAdapter.instances.clear()
def test_server_service_uses_transport_aware_core_with_gns_disabled_by_default(tmp_path):
config = ServerConfig(bans_path=str(tmp_path / "bans.json"))
assert not config.enable_gns_transport
service = ServerService(config)
core = service._create_server()
assert isinstance(core, TransportAwareFalloutTogetherServer)
def test_gns_feature_flag_is_noop_when_disabled(monkeypatch):
reset_fakes()
monkeypatch.setattr(server_service, "GnsServerTransport", FakeTransport)
monkeypatch.setattr(server_service, "GnsGameplayAdapter", FakeAdapter)
service = ServerService(ServerConfig(enable_gns_transport=False))
service._server = FakeCoreServer()
service._start_gns()
assert service._gns_adapter is None
assert FakeTransport.instances == []
assert FakeAdapter.instances == []
def test_gns_feature_flag_starts_udp_on_same_numeric_port_and_stops_cleanly(monkeypatch):
reset_fakes()
monkeypatch.setattr(server_service, "GnsServerTransport", FakeTransport)
monkeypatch.setattr(server_service, "GnsGameplayAdapter", FakeAdapter)
service = ServerService(
ServerConfig(
host="127.0.0.1",
port=7777,
enable_gns_transport=True,
gns_bridge_path="/opt/commonwealth/libcommonwealth_online_gns_bridge.so",
)
)
service._server = FakeCoreServer(7777)
service._start_gns()
assert len(FakeTransport.instances) == 1
transport = FakeTransport.instances[0]
assert transport.host == "127.0.0.1"
assert transport.requested_port == 7777
assert transport.library_path == "/opt/commonwealth/libcommonwealth_online_gns_bridge.so"
assert len(FakeAdapter.instances) == 1
adapter = FakeAdapter.instances[0]
assert adapter.started
assert service._gns_adapter is adapter
service._stop_gns()
assert adapter.stopped
assert transport.closed
assert service._gns_adapter is None
def test_gns_start_rejects_native_bridge_bound_to_wrong_udp_port(monkeypatch):
reset_fakes()
FakeTransport.local_port_override = 8888
monkeypatch.setattr(server_service, "GnsServerTransport", FakeTransport)
monkeypatch.setattr(server_service, "GnsGameplayAdapter", FakeAdapter)
service = ServerService(ServerConfig(port=7777, enable_gns_transport=True))
service._server = FakeCoreServer(7777)
with pytest.raises(RuntimeError, match="expected UDP 7777"):
service._start_gns()
assert len(FakeTransport.instances) == 1
assert FakeTransport.instances[0].closed
assert FakeAdapter.instances == []
assert service._gns_adapter is None
-105
View File
@@ -1,105 +0,0 @@
from __future__ import annotations
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
import pytest
from admin_server import send_admin_command
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_deadline = time.time() + 5.0
response: dict[str, object] | None = None
while time.time() < admin_deadline:
try:
response = send_admin_command({"cmd": "ping"}, port=admin_port, timeout_seconds=0.5)
break
except (OSError, RuntimeError, ConnectionError):
time.sleep(0.05)
assert response is not None
assert response.get("ok") is True
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)
-51
View File
@@ -1,51 +0,0 @@
from snapshot_sequence import MAX_SEQUENCE, SequenceCounter, SequenceWindow, is_newer, next_sequence
def test_next_sequence_skips_zero_after_wrap():
assert next_sequence(0) == 1
assert next_sequence(1) == 2
assert next_sequence(MAX_SEQUENCE) == 1
def test_is_newer_is_wrap_safe():
assert is_newer(1, 0)
assert is_newer(2, 1)
assert not is_newer(1, 1)
assert not is_newer(1, 2)
assert is_newer(1, MAX_SEQUENCE)
assert not is_newer(MAX_SEQUENCE, 1)
assert not is_newer(0, MAX_SEQUENCE)
def test_counter_and_window_enforce_latest_wins():
counter = SequenceCounter()
assert counter.current == 0
assert counter.advance() == 1
assert counter.advance() == 2
counter.reset()
assert counter.advance() == 1
window = SequenceWindow()
assert window.accept(1)
assert not window.accept(1)
assert window.accept(2)
assert not window.accept(1)
window.reset()
assert window.accept(MAX_SEQUENCE)
assert window.accept(1)
assert not window.accept(MAX_SEQUENCE)
def test_loss_and_reordering_do_not_stall_newer_snapshots():
window = SequenceWindow()
arrivals = (1, 3, 2, 6, 4, 5, 6, 8)
accepted = [sequence for sequence in arrivals if window.accept(sequence)]
assert accepted == [1, 3, 6, 8]
assert window.last_accepted == 8
def test_invalid_sequence_inputs_are_rejected():
assert not is_newer(-1, 0)
assert not is_newer(1, -1)
assert not is_newer(True, 0)
assert not is_newer(1, MAX_SEQUENCE + 1)
-50
View File
@@ -1,50 +0,0 @@
import socket
import pytest
from packet_codec import MAX_MESSAGE_BYTES, encode_packet
from tcp_transport import LineMessageBuffer, TcpFramingError, frame_message, send_message
def test_tcp_framing_adds_delimiter_only_at_transport_boundary():
encoded = encode_packet({"type": "playerState", "characterName": "Nomad"})
assert not encoded.payload.endswith(b"\n")
framed = frame_message(encoded.payload)
assert framed == encoded.payload + b"\n"
def test_line_buffer_preserves_fragmented_and_coalesced_messages():
buffer = LineMessageBuffer()
assert buffer.feed(b'{"type":"first"') == []
messages = buffer.feed(b'}\n{"type":"second"}\n{"type":"third"')
assert messages == [b'{"type":"first"}', b'{"type":"second"}']
assert buffer.buffered_bytes > 0
assert buffer.feed(b'}\r\n') == [b'{"type":"third"}']
assert buffer.buffered_bytes == 0
def test_line_buffer_rejects_oversize_unterminated_and_terminated_messages():
with pytest.raises(TcpFramingError):
LineMessageBuffer().feed(b"x" * (MAX_MESSAGE_BYTES + 1))
with pytest.raises(TcpFramingError):
LineMessageBuffer().feed((b"x" * (MAX_MESSAGE_BYTES + 1)) + b"\n")
def test_frame_rejects_raw_delimiters_and_oversize_payloads():
with pytest.raises(TcpFramingError):
frame_message(b"bad\nmessage")
with pytest.raises(TcpFramingError):
frame_message(b"bad\rmessage")
with pytest.raises(TcpFramingError):
frame_message(b"x" * (MAX_MESSAGE_BYTES + 1))
def test_send_message_preserves_existing_tcp_wire_format():
reader, writer = socket.socketpair()
try:
payload = encode_packet({"type": "keepAlive"}).payload
send_message(writer, payload)
assert reader.recv(4096) == payload + b"\n"
finally:
reader.close()
writer.close()
-40
View File
@@ -1,40 +0,0 @@
from transport_policy import (
Delivery,
delivery_for_packet_type,
is_snapshot_packet,
requires_application_sequence,
)
def test_snapshot_packets_use_unreliable_sequenced_delivery():
assert delivery_for_packet_type("transform") is Delivery.UNRELIABLE_SEQUENCED
assert delivery_for_packet_type("npcState") is Delivery.UNRELIABLE_SEQUENCED
assert is_snapshot_packet("transform")
assert is_snapshot_packet("npcState")
assert requires_application_sequence("transform")
assert requires_application_sequence("npcState")
def test_gameplay_and_control_packets_use_reliable_ordered_delivery():
reliable_types = (
"hello",
"sessionReady",
"worldState",
"combatHit",
"npcAuthority",
"positionCorrection",
"disconnect",
"sessionEnded",
"equipmentState",
"actionEvent",
"playerState",
)
for packet_type in reliable_types:
assert delivery_for_packet_type(packet_type) is Delivery.RELIABLE_ORDERED
assert not is_snapshot_packet(packet_type)
assert not requires_application_sequence(packet_type)
def test_unknown_packet_types_never_default_to_unreliable():
assert delivery_for_packet_type("futureControlPacket") is Delivery.RELIABLE_ORDERED
assert not requires_application_sequence("futureControlPacket")
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
import socket
import time
import pytest
from client_session import ClientSession
from packet_codec import EncodedPacket
from transport_policy import Delivery
from transport_server import TransportAwareFalloutTogetherServer
class MessageConnection:
def __init__(self):
self.messages: list[EncodedPacket] = []
def send_encoded(self, encoded: EncodedPacket) -> None:
self.messages.append(encoded)
def fileno(self) -> int:
return 1
def close(self) -> None:
pass
def make_client(connection) -> ClientSession:
return ClientSession(
connection=connection,
address=("127.0.0.1", 50000),
player_id=1,
connected_at=time.time(),
)
def test_transport_aware_server_preserves_tcp_wire_format():
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
reader, writer = socket.socketpair()
try:
client = make_client(writer)
server._send_packet(client, {"type": "playerState", "characterName": "Nomad"})
assert reader.recv(4096) == b'{"type":"playerState","characterName":"Nomad"}\n'
assert client.packets_sent == 1
assert server.get_stats()["packetsSent"] == 1
finally:
reader.close()
writer.close()
def test_transport_aware_server_sends_raw_message_and_delivery_metadata_to_gns_style_connection():
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
connection = MessageConnection()
client = make_client(connection)
server._send_packet(client, {"type": "transform", "x": 1.0}, broadcast=True)
server._send_packet(client, {"type": "playerState", "characterName": "Nomad"})
assert len(connection.messages) == 2
transform, player_state = connection.messages
assert transform.payload == b'{"type":"transform","x":1.0}'
assert transform.delivery is Delivery.UNRELIABLE_SEQUENCED
assert not transform.payload.endswith(b"\n")
assert player_state.delivery is Delivery.RELIABLE_ORDERED
assert player_state.payload == b'{"type":"playerState","characterName":"Nomad"}'
assert client.packets_sent == 2
assert client.packets_broadcast == 1
assert server.get_stats()["packetsSent"] == 2
assert server.get_stats()["packetsBroadcast"] == 1
def test_transport_aware_server_rejects_nonfinite_json_before_transport():
server = TransportAwareFalloutTogetherServer(host="127.0.0.1", port=0)
connection = MessageConnection()
client = make_client(connection)
with pytest.raises(ValueError):
server._send_packet(client, {"type": "transform", "x": float("nan")})
assert connection.messages == []
assert server.get_stats()["packetsSent"] == 0
-38
View File
@@ -1,38 +0,0 @@
from __future__ import annotations
from enum import Enum
class Delivery(str, Enum):
UNRELIABLE_SEQUENCED = "unreliable_sequenced"
RELIABLE_ORDERED = "reliable_ordered"
_SNAPSHOT_PACKET_TYPES = frozenset({"transform", "npcState"})
def delivery_for_packet_type(packet_type: str) -> Delivery:
"""Return the transport contract for one validated protocol packet type.
Unknown packet types intentionally default to reliable/ordered. Protocol
validation still decides whether an unknown packet is accepted; this
function only prevents a future transport adapter from accidentally
downgrading control traffic to an unreliable channel.
"""
if packet_type in _SNAPSHOT_PACKET_TYPES:
return Delivery.UNRELIABLE_SEQUENCED
return Delivery.RELIABLE_ORDERED
def is_snapshot_packet(packet_type: str) -> bool:
return delivery_for_packet_type(packet_type) is Delivery.UNRELIABLE_SEQUENCED
def requires_application_sequence(packet_type: str) -> bool:
"""Return whether the protocol must reject stale copies after reordering.
GameNetworkingSockets can deliver unreliable messages without retransmit,
but the application still owns latest-wins snapshot semantics. Sequence
numbers are therefore required for every unreliable snapshot family.
"""
return is_snapshot_packet(packet_type)
-34
View File
@@ -1,34 +0,0 @@
from __future__ import annotations
from typing import Any
from client_session import ClientSession
from packet_codec import PacketCodecError, encode_packet
from server_core import MAX_PACKET_CHARS, FalloutTogetherServer
class TransportAwareFalloutTogetherServer(FalloutTogetherServer):
"""Authoritative server with serialization separated from transport framing.
All gameplay validation and relay behavior stays in ``FalloutTogetherServer``.
Only the final outbound serialization boundary is replaced: TCP sessions add
line framing in ``ClientSession.send_packet`` while message transports receive
the raw encoded packet and delivery policy.
"""
def _send_packet(self, client: ClientSession, packet: dict[str, Any], *, broadcast: bool = False) -> None:
try:
encoded = encode_packet(packet)
except PacketCodecError as error:
raise ValueError(str(error)) from error
uses_message_transport = callable(getattr(client.connection, "send_encoded", None))
if not uses_message_transport and len(encoded.payload) + 1 > MAX_PACKET_CHARS:
raise ValueError("Outbound TCP packet exceeds maximum line size")
client.send_packet(encoded)
client.record_sent(broadcast=broadcast)
with self._lock:
self._stats["packetsSent"] += 1
if broadcast:
self._stats["packetsBroadcast"] += 1
-67
View File
@@ -1,67 +0,0 @@
"""Preset weather and time values for the dev server Weather/Time tab."""
from __future__ import annotations
# Each preset is (label, fw_console_arg).
# `fw_console_arg` is passed directly to the in-game `fw` command on the
# current world-state host client
# (8-digit hex form ID, not an editor name like CommonwealthRain).
WEATHER_PRESETS: list[tuple[str, str]] = [
("Clear", "0002b52a"),
("Cloudy", "001cc186"),
("Overcast", "001c8556"),
("Fog", "001c3473"),
("Rain", "001ca7e4"),
("Radstorm", "001c3d5e"),
("Glowing Sea", "000f1033"),
]
# HHmm values for the in-game `set gamehour to HHmm` console command.
TIME_PRESETS: list[tuple[str, str]] = [
("Midnight", "0000"),
("Dawn", "0600"),
("Morning", "0900"),
("Noon", "1200"),
("Afternoon", "1500"),
("Evening", "1800"),
("Dusk (7 PM)", "1900"),
("Night", "2200"),
]
def normalize_fw_console_arg(value: str) -> str:
text = str(value).strip().lower()
if text.startswith("0x"):
text = text[2:]
return f"{int(text, 16):08x}"
def relay_weather_form_id(fw_console_arg: str) -> str:
return f"{int(fw_console_arg, 16):08X}"
def hhmm_to_game_hour(hhmm: str) -> float | None:
text = str(hhmm).strip()
if not text.isdigit() or len(text) > 4:
return None
padded = text.zfill(4)
hours = int(padded[:2])
minutes = int(padded[2:])
if hours > 23 or minutes > 59:
return None
return hours + (minutes / 60.0)
def format_hhmm_label(hhmm: str) -> str:
text = str(hhmm).strip().zfill(4)
hours = int(text[:2])
minutes = int(text[2:])
suffix = "AM" if hours < 12 else "PM"
display_hour = hours % 12
if display_hour == 0:
display_hour = 12
if minutes:
return f"{display_hour}:{minutes:02d} {suffix}"
return f"{display_hour} {suffix}"
+113 -160
View File
@@ -1,17 +1,26 @@
#include "ServerProcess.h"
#include <QCoreApplication>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHostAddress>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
namespace {
constexpr int kDefaultAdminPort = 7779;
constexpr int kAdminTimeoutMs = 3000;
constexpr auto kAdminTokenFileName = ".admin-token";
QString appHostName() {
#ifdef Q_OS_WIN
return QStringLiteral("CommonwealthOnline.Server.exe");
#else
return QStringLiteral("CommonwealthOnline.Server");
#endif
}
}
ServerProcess::ServerProcess(QObject *parent)
@@ -21,8 +30,8 @@ ServerProcess::ServerProcess(QObject *parent)
, m_adminPort(kDefaultAdminPort)
, adminFailCount(0)
{
pythonPath = findPythonExecutable();
serverDir = findServerDirectory();
resolveServerLaunch();
}
ServerProcess::~ServerProcess() {
@@ -39,42 +48,33 @@ int ServerProcess::adminPort() const {
void ServerProcess::start(const QString &configPath) {
if (running) {
emit error("Server is already running");
emit error(QStringLiteral("Server is already running"));
return;
}
if (pythonPath.isEmpty()) {
emit error("Python not found. Please install Python 3.9+ and add it to PATH");
return;
}
if (serverDir.isEmpty()) {
emit error("Server directory not found");
emit error(QStringLiteral("C# server directory not found"));
return;
}
if (serverProgram.isEmpty() && !resolveServerLaunch()) {
emit error(QStringLiteral("CommonwealthOnline.Server executable was not found. Build or publish the .NET server first."));
return;
}
process = new QProcess(this);
connect(process, SIGNAL(finished(int, QProcess::ExitStatus)),
this, SLOT(onProcessFinished(int, QProcess::ExitStatus)));
connect(process, SIGNAL(error(QProcess::ProcessError)),
this, SLOT(onProcessError(QProcess::ProcessError)));
connect(process, SIGNAL(readyReadStandardOutput()),
this, SLOT(onReadyReadStandardOutput()));
connect(process, SIGNAL(readyReadStandardError()),
this, SLOT(onReadyReadStandardError()));
connect(process, SIGNAL(started()),
this, SLOT(onProcessStarted()));
QStringList arguments;
arguments << "consumer_server_cli.py" << "serve" << "--config" << configPath;
connect(process, SIGNAL(finished(int, QProcess::ExitStatus)), this, SLOT(onProcessFinished(int, QProcess::ExitStatus)));
connect(process, SIGNAL(error(QProcess::ProcessError)), this, SLOT(onProcessError(QProcess::ProcessError)));
connect(process, SIGNAL(readyReadStandardOutput()), this, SLOT(onReadyReadStandardOutput()));
connect(process, SIGNAL(readyReadStandardError()), this, SLOT(onReadyReadStandardError()));
connect(process, SIGNAL(started()), this, SLOT(onProcessStarted()));
QStringList arguments = serverPrefixArguments;
arguments << QStringLiteral("serve") << QStringLiteral("--config") << configPath;
process->setWorkingDirectory(serverDir);
process->start(pythonPath, arguments);
process->start(serverProgram, arguments);
}
void ServerProcess::stop() {
if (!process) return;
if (process->state() == QProcess::Running) {
process->terminate();
if (!process->waitForFinished(3000)) {
@@ -82,33 +82,24 @@ void ServerProcess::stop() {
process->waitForFinished();
}
}
process->deleteLater();
process = nullptr;
running = false;
adminFailCount = 0;
}
QJsonObject ServerProcess::sendAdminCommand(const QJsonObject &request) {
if (serverDir.isEmpty()) {
return QJsonObject{
{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("Server directory is unavailable for admin authentication")}
};
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Server directory is unavailable for admin authentication")}};
}
QFile tokenFile(QDir(serverDir).absoluteFilePath(QString::fromLatin1(kAdminTokenFileName)));
if (!tokenFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
return QJsonObject{
{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("Admin authentication token is not available yet")}
};
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Admin authentication token is not available yet")}};
}
const QByteArray token = tokenFile.readAll().trimmed();
tokenFile.close();
if (token.isEmpty()) {
return QJsonObject{
{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("Admin authentication token is empty")}
};
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Admin authentication token is empty")}};
}
QJsonObject authenticatedRequest = request;
@@ -117,128 +108,97 @@ QJsonObject ServerProcess::sendAdminCommand(const QJsonObject &request) {
QTcpSocket socket;
socket.connectToHost(QStringLiteral("127.0.0.1"), static_cast<quint16>(m_adminPort));
if (!socket.waitForConnected(kAdminTimeoutMs)) {
return QJsonObject{
{QStringLiteral("ok"), false},
{QStringLiteral("error"),
QStringLiteral("Could not connect to admin port 127.0.0.1:%1 (%2)")
.arg(m_adminPort)
.arg(socket.errorString())}
};
return {{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("Could not connect to admin port 127.0.0.1:%1 (%2)").arg(m_adminPort).arg(socket.errorString())}};
}
const QByteArray payload = QJsonDocument(authenticatedRequest).toJson(QJsonDocument::Compact) + '\n';
if (socket.write(payload) < 0 || !socket.waitForBytesWritten(kAdminTimeoutMs)) {
return QJsonObject{
{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("Failed to send admin command")}
};
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Failed to send admin command")}};
}
QByteArray buffer;
while (!buffer.contains('\n')) {
if (!socket.waitForReadyRead(kAdminTimeoutMs)) {
return QJsonObject{
{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("Timed out waiting for admin response")}
};
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Timed out waiting for admin response")}};
}
buffer += socket.readAll();
if (buffer.size() > 64 * 1024) {
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Admin response exceeded maximum size")}};
}
}
const int newline = buffer.indexOf('\n');
const QByteArray line = buffer.left(newline);
const QByteArray line = buffer.left(buffer.indexOf('\n'));
QJsonParseError parseError{};
const QJsonDocument doc = QJsonDocument::fromJson(line, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
return QJsonObject{
{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("Invalid admin response JSON")}
};
return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Invalid admin response JSON")}};
}
return doc.object();
}
void ServerProcess::fetchStats() {
if (!running) return;
const QJsonObject statsResponse = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("stats")}});
const QJsonObject statsResponse = sendAdminCommand({{QStringLiteral("cmd"), QStringLiteral("stats")}});
if (statsResponse.value(QStringLiteral("ok")).toBool()) {
adminFailCount = 0;
emit statsUpdated(statsResponse.value(QStringLiteral("data")).toObject());
} else {
++adminFailCount;
if (adminFailCount == 3 || adminFailCount == 10 || (adminFailCount % 30) == 0) {
emit logMessage(QStringLiteral("[ADMIN] %1")
.arg(statsResponse.value(QStringLiteral("error"))
.toString(QStringLiteral("Admin stats request failed."))));
emit logMessage(QStringLiteral("[ADMIN] %1").arg(statsResponse.value(QStringLiteral("error")).toString(QStringLiteral("Admin stats request failed."))));
}
return;
}
const QJsonObject clientsResponse = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("clients")}});
const QJsonObject clientsResponse = sendAdminCommand({{QStringLiteral("cmd"), QStringLiteral("clients")}});
if (clientsResponse.value(QStringLiteral("ok")).toBool()) {
const QJsonObject data = clientsResponse.value(QStringLiteral("data")).toObject();
emit clientsUpdated(data.value(QStringLiteral("clients")).toArray());
emit clientsUpdated(clientsResponse.value(QStringLiteral("data")).toObject().value(QStringLiteral("clients")).toArray());
} else {
emit logMessage(QStringLiteral("[ADMIN] %1")
.arg(clientsResponse.value(QStringLiteral("error"))
.toString(QStringLiteral("Admin clients request failed."))));
emit logMessage(QStringLiteral("[ADMIN] %1").arg(clientsResponse.value(QStringLiteral("error")).toString(QStringLiteral("Admin clients request failed."))));
}
}
bool ServerProcess::kickPlayer(int playerId, const QString &reason) {
QJsonObject request{
const QJsonObject response = sendAdminCommand({
{QStringLiteral("cmd"), QStringLiteral("kick")},
{QStringLiteral("playerId"), playerId},
{QStringLiteral("reason"), reason}
};
const QJsonObject response = sendAdminCommand(request);
});
const bool ok = response.value(QStringLiteral("ok")).toBool();
const QString message = ok
? response.value(QStringLiteral("message")).toString(QStringLiteral("Player kicked."))
: response.value(QStringLiteral("error")).toString(QStringLiteral("Kick failed."));
emit adminCommandFinished(ok, message);
emit adminCommandFinished(ok, ok ? response.value(QStringLiteral("message")).toString(QStringLiteral("Player kicked."))
: response.value(QStringLiteral("error")).toString(QStringLiteral("Kick failed.")));
return ok;
}
bool ServerProcess::banPlayer(int playerId, const QString &reason) {
QJsonObject request{
const QJsonObject response = sendAdminCommand({
{QStringLiteral("cmd"), QStringLiteral("ban")},
{QStringLiteral("playerId"), playerId},
{QStringLiteral("reason"), reason}
};
const QJsonObject response = sendAdminCommand(request);
});
const bool ok = response.value(QStringLiteral("ok")).toBool();
const QString message = ok
? response.value(QStringLiteral("message")).toString(QStringLiteral("Player banned."))
: response.value(QStringLiteral("error")).toString(QStringLiteral("Ban failed."));
emit adminCommandFinished(ok, message);
if (ok) {
fetchStats();
}
emit adminCommandFinished(ok, ok ? response.value(QStringLiteral("message")).toString(QStringLiteral("Player banned."))
: response.value(QStringLiteral("error")).toString(QStringLiteral("Ban failed.")));
if (ok) fetchStats();
return ok;
}
bool ServerProcess::unbanIp(const QString &ip) {
QJsonObject request{
const QJsonObject response = sendAdminCommand({
{QStringLiteral("cmd"), QStringLiteral("unban")},
{QStringLiteral("ip"), ip}
};
const QJsonObject response = sendAdminCommand(request);
});
const bool ok = response.value(QStringLiteral("ok")).toBool();
const QString message = ok
? response.value(QStringLiteral("message")).toString(QStringLiteral("IP unbanned."))
: response.value(QStringLiteral("error")).toString(QStringLiteral("Unban failed."));
emit adminCommandFinished(ok, message);
emit adminCommandFinished(ok, ok ? response.value(QStringLiteral("message")).toString(QStringLiteral("IP unbanned."))
: response.value(QStringLiteral("error")).toString(QStringLiteral("Unban failed.")));
return ok;
}
QJsonArray ServerProcess::listBans() {
const QJsonObject response = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("bans")}});
const QJsonObject response = sendAdminCommand({{QStringLiteral("cmd"), QStringLiteral("bans")}});
if (!response.value(QStringLiteral("ok")).toBool()) {
emit adminCommandFinished(
false,
response.value(QStringLiteral("error")).toString(QStringLiteral("Could not list bans.")));
emit adminCommandFinished(false, response.value(QStringLiteral("error")).toString(QStringLiteral("Could not list bans.")));
return {};
}
return response.value(QStringLiteral("data")).toObject().value(QStringLiteral("bans")).toArray();
@@ -262,44 +222,29 @@ void ServerProcess::onProcessFinished(int exitCode, QProcess::ExitStatus exitSta
running = false;
adminFailCount = 0;
emit stopped();
if (exitStatus == QProcess::NormalExit) {
emit logMessage(QString("Server exited with code %1").arg(exitCode));
} else {
emit error("Server process crashed");
}
if (exitStatus == QProcess::NormalExit) emit logMessage(QStringLiteral("Server exited with code %1").arg(exitCode));
else emit error(QStringLiteral("Server process crashed"));
}
void ServerProcess::onProcessError(QProcess::ProcessError error) {
QString errorString;
switch (error) {
case QProcess::FailedToStart:
errorString = "Failed to start Python process";
break;
case QProcess::Crashed:
errorString = "Server process crashed";
break;
case QProcess::Timedout:
errorString = "Server process timed out";
break;
default:
errorString = "Unknown process error";
void ServerProcess::onProcessError(QProcess::ProcessError processError) {
QString message;
switch (processError) {
case QProcess::FailedToStart: message = QStringLiteral("Failed to start CommonwealthOnline.Server"); break;
case QProcess::Crashed: message = QStringLiteral("Server process crashed"); break;
case QProcess::Timedout: message = QStringLiteral("Server process timed out"); break;
default: message = QStringLiteral("Unknown server process error"); break;
}
emit this->error(errorString);
emit error(message);
}
void ServerProcess::onReadyReadStandardOutput() {
if (!process) return;
outputBuffer += process->readAllStandardOutput();
while (outputBuffer.contains('\n')) {
int newlinePos = outputBuffer.indexOf('\n');
QString line = outputBuffer.left(newlinePos);
const int newlinePos = outputBuffer.indexOf('\n');
QString line = outputBuffer.left(newlinePos).trimmed();
outputBuffer = outputBuffer.mid(newlinePos + 1);
if (!line.isEmpty()) {
line = line.trimmed();
parseLogLine(line);
emit logMessage(line);
}
@@ -308,51 +253,59 @@ void ServerProcess::onReadyReadStandardOutput() {
void ServerProcess::onReadyReadStandardError() {
if (!process) return;
QString errorOutput = process->readAllStandardError();
emit logMessage("[STDERR] " + errorOutput);
}
QString ServerProcess::findPythonExecutable() {
QProcess proc;
proc.start("python", QStringList() << "--version");
if (proc.waitForFinished(2000)) {
return "python";
}
proc.start("python3", QStringList() << "--version");
if (proc.waitForFinished(2000)) {
return "python3";
}
return "";
const QString value = QString::fromUtf8(process->readAllStandardError()).trimmed();
if (!value.isEmpty()) emit logMessage(QStringLiteral("[STDERR] ") + value);
}
QString ServerProcess::findServerDirectory() {
QDir dir(QCoreApplication::applicationDirPath());
for (int i = 0; i < 8; ++i) {
const QString candidate = dir.absoluteFilePath(QStringLiteral("server"));
if (QFileInfo::exists(candidate + QStringLiteral("/consumer_server_cli.py")) &&
QFileInfo::exists(candidate + QStringLiteral("/admin_server.py"))) {
if (QFileInfo::exists(candidate + QStringLiteral("/CommonwealthOnline.Server.csproj")) ||
QFileInfo::exists(candidate + QStringLiteral("/CommonwealthOnline.Server.dll")) ||
QFileInfo::exists(candidate + QLatin1Char('/') + appHostName())) {
return QFileInfo(candidate).absoluteFilePath();
}
if (!dir.cdUp()) {
break;
if (!dir.cdUp()) break;
}
return {};
}
bool ServerProcess::resolveServerLaunch() {
serverProgram.clear();
serverPrefixArguments.clear();
if (serverDir.isEmpty()) return false;
const QDir dir(serverDir);
const QStringList appHostCandidates = {
dir.absoluteFilePath(appHostName()),
dir.absoluteFilePath(QStringLiteral("publish/") + appHostName()),
dir.absoluteFilePath(QStringLiteral("bin/Release/net8.0/") + appHostName())
};
for (const QString &candidate : appHostCandidates) {
if (QFileInfo::exists(candidate)) {
serverProgram = QFileInfo(candidate).absoluteFilePath();
return true;
}
}
dir = QDir(QCoreApplication::applicationDirPath());
for (int i = 0; i < 8; ++i) {
const QString candidate = dir.absoluteFilePath(QStringLiteral("server"));
if (QFileInfo::exists(candidate + QStringLiteral("/consumer_server_cli.py"))) {
return QFileInfo(candidate).absoluteFilePath();
}
if (!dir.cdUp()) {
break;
}
const QStringList dllCandidates = {
dir.absoluteFilePath(QStringLiteral("CommonwealthOnline.Server.dll")),
dir.absoluteFilePath(QStringLiteral("publish/CommonwealthOnline.Server.dll")),
dir.absoluteFilePath(QStringLiteral("bin/Release/net8.0/CommonwealthOnline.Server.dll"))
};
QString dllPath;
for (const QString &candidate : dllCandidates) {
if (QFileInfo::exists(candidate)) { dllPath = QFileInfo(candidate).absoluteFilePath(); break; }
}
if (dllPath.isEmpty()) return false;
return QString();
QProcess probe;
probe.start(QStringLiteral("dotnet"), {QStringLiteral("--version")});
if (!probe.waitForFinished(3000) || probe.exitStatus() != QProcess::NormalExit || probe.exitCode() != 0) return false;
serverProgram = QStringLiteral("dotnet");
serverPrefixArguments << dllPath;
return true;
}
void ServerProcess::parseLogLine(const QString &line) {
+4 -3
View File
@@ -43,18 +43,19 @@ private slots:
void onReadyReadStandardError();
private:
QString findPythonExecutable();
QString findServerDirectory();
bool resolveServerLaunch();
void parseLogLine(const QString &line);
QJsonObject sendAdminCommand(const QJsonObject &request);
QProcess *process;
QString pythonPath;
QString serverDir;
QString serverProgram;
QStringList serverPrefixArguments;
bool running;
QString outputBuffer;
int m_adminPort;
int adminFailCount;
};
#endif // SERVERPROCESS_H
#endif