diff --git a/.gitattributes b/.gitattributes index 09cbdbb..33e9be6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/csharp-server.yml b/.github/workflows/csharp-server.yml index e264962..1583f3d 100644 --- a/.github/workflows/csharp-server.yml +++ b/.github/workflows/csharp-server.yml @@ -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 diff --git a/.github/workflows/linux-compatibility.yml b/.github/workflows/linux-compatibility.yml deleted file mode 100644 index 14e7ba9..0000000 --- a/.github/workflows/linux-compatibility.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/networking-v2-selfhosted.yml b/.github/workflows/networking-v2-selfhosted.yml deleted file mode 100644 index f72be91..0000000 --- a/.github/workflows/networking-v2-selfhosted.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 6b39930..974c893 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 637a87c..4664a52 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 "$/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 ) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 9886247..136f19d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1d2717f..4edf387 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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. diff --git a/README.md b/README.md index bfa32ae..981c078 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/SETUP.md b/SETUP.md index 857a6a6..e4c1504 100644 --- a/SETUP.md +++ b/SETUP.md @@ -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. diff --git a/build.bat b/build.bat index 3661a60..19835dc 100644 --- a/build.bat +++ b/build.bat @@ -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 diff --git a/check-setup.bat b/check-setup.bat index 1307033..2109b39 100644 --- a/check-setup.bat +++ b/check-setup.bat @@ -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 diff --git a/cmake/stage_server.cmake b/cmake/stage_server.cmake index 748655b..dfbbd5d 100644 --- a/cmake/stage_server.cmake +++ b/cmake/stage_server.cmake @@ -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() diff --git a/deploy.bat b/deploy.bat index 836da31..4cf54e6 100644 --- a/deploy.bat +++ b/deploy.bat @@ -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 diff --git a/server/PORT_TROUBLESHOOTING.txt b/server/PORT_TROUBLESHOOTING.txt index 54409c3..8bf8e94 100644 --- a/server/PORT_TROUBLESHOOTING.txt +++ b/server/PORT_TROUBLESHOOTING.txt @@ -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 /F @@ -49,22 +45,17 @@ Linux / macOS: lsof -i :7777 kill -9 -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). ================================================================================ diff --git a/server/README.md b/server/README.md index 4f1e692..a8c7127 100644 --- a/server/README.md +++ b/server/README.md @@ -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. diff --git a/server/admin_server.py b/server/admin_server.py deleted file mode 100644 index 21b9a26..0000000 --- a/server/admin_server.py +++ /dev/null @@ -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 diff --git a/server/ban_store.py b/server/ban_store.py deleted file mode 100644 index 98a7787..0000000 --- a/server/ban_store.py +++ /dev/null @@ -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) - ] diff --git a/server/client_session.py b/server/client_session.py deleted file mode 100644 index f1e0e7b..0000000 --- a/server/client_session.py +++ /dev/null @@ -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, - } diff --git a/server/commonwealth-online.service.example b/server/commonwealth-online.service.example index 39a04e9..c9dda64 100644 --- a/server/commonwealth-online.service.example +++ b/server/commonwealth-online.service.example @@ -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 diff --git a/server/config.py b/server/config.py deleted file mode 100644 index ecbba68..0000000 --- a/server/config.py +++ /dev/null @@ -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 diff --git a/server/config/README.md b/server/config/README.md index c671848..7dfe45b 100644 --- a/server/config/README.md +++ b/server/config/README.md @@ -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. diff --git a/server/consumer_server_cli.py b/server/consumer_server_cli.py deleted file mode 100644 index b78187b..0000000 --- a/server/consumer_server_cli.py +++ /dev/null @@ -1,1146 +0,0 @@ -#!/usr/bin/env python3 -""" -Commonwealth Online Consumer Server CLI. - -Production-ready command-line interface for hosting Commonwealth Online servers -in cloud and on-premises environments. - -Usage: - commonwealth help - commonwealth serve [--config CONFIG_PATH] [--host HOST] [--port PORT] - commonwealth status - commonwealth clients - commonwealth users - commonwealth kick PLAYER_ID [--reason REASON] - commonwealth ban PLAYER_ID_OR_IP [--reason REASON] - commonwealth unban IP - commonwealth bans - commonwealth world time HHmm - commonwealth world weather FORM_ID - commonwealth config init OUTPUT_PATH -""" - -from __future__ import annotations - -import json -import logging -import os -import shlex -import signal -import sys -import threading -import time -from datetime import datetime -from pathlib import Path -from typing import Any, Optional - -import typer -from rich.console import Console, Group, RenderableType -from rich.live import Live -from rich.table import Table -from rich.panel import Panel -from rich.text import Text -from rich import box - -from admin_server import send_admin_command -from server_service import ServerService, ServerConfig, looks_like_ipv4 -from config import ( - Config, - load_config, - generate_default_config, - validate_config, - ensure_writable_directory, - DEFAULT_ADMIN_PORT as CONFIG_DEFAULT_ADMIN_PORT, -) - -# Rich console for beautiful output when attached to a TTY. -_FORCE_COLOR = os.environ.get("FORCE_COLOR", "").strip() not in ("", "0", "false", "False") -_USE_COLOR = _FORCE_COLOR or (sys.stdout.isatty() and os.environ.get("NO_COLOR") is None) -console = Console(force_terminal=_USE_COLOR, color_system="auto" if _USE_COLOR else None) -app = typer.Typer( - name="commonwealth", - help="Commonwealth Online Server CLI", - pretty_exceptions_enable=False, -) - -# Global service instance (used by the serve process only) -_service: Optional[ServerService] = None -_shutdown_requested = threading.Event() -_logger = logging.getLogger("commonwealth.server") - - -def get_service() -> ServerService: - """Get or initialize the global service instance.""" - global _service - if _service is None: - _service = ServerService() - return _service - - -def _configure_logging() -> None: - if _logger.handlers: - return - handler = logging.StreamHandler(sys.stdout) - handler.setFormatter( - logging.Formatter( - fmt="%(asctime)s %(levelname)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - ) - handler.flush = sys.stdout.flush # type: ignore[method-assign] - _logger.setLevel(logging.INFO) - _logger.addHandler(handler) - _logger.propagate = False - - -def log_callback(message: str, *, level: str = "info") -> None: - """Callback for server logs from the service.""" - severity = str(level or "info").strip().lower() - if _USE_COLOR: - timestamp = datetime.now().strftime("%H:%M:%S") - console.print(f"[dim]{timestamp}[/dim] [{severity}] {message}") - return - - _configure_logging() - log_level = { - "debug": logging.DEBUG, - "info": logging.INFO, - "warning": logging.WARNING, - "error": logging.ERROR, - }.get(severity, logging.INFO) - _logger.log(log_level, message) - for handler in _logger.handlers: - handler.flush() - - -def _require_python_version() -> None: - if sys.version_info < (3, 9): - console.print( - f"[red]ERROR: Python 3.9+ is required (found {sys.version.split()[0]}).[/red]" - ) - raise typer.Exit(code=1) - - -def _request_shutdown(_signum: int, _frame: Any) -> None: - """Handle SIGINT/SIGTERM by requesting a clean shutdown.""" - _shutdown_requested.set() - service = _service - if service is not None: - try: - service.stop() - except Exception: - pass - # Interrupt blocking main-thread waits (accept/input) so shutdown completes. - raise KeyboardInterrupt - - -def admin_request( - request: dict[str, Any], - *, - admin_port: int = CONFIG_DEFAULT_ADMIN_PORT, -) -> dict[str, Any]: - """Send an admin command to the running server, or exit with an error.""" - try: - return send_admin_command(request, port=admin_port) - except OSError: - console.print( - f"[red]Error: Could not reach admin port 127.0.0.1:{admin_port}. " - "Is the server running?[/red]" - ) - raise typer.Exit(code=1) - except (ConnectionError, ValueError, json.JSONDecodeError) as error: - console.print(f"[red]Error talking to admin port: {error}[/red]") - raise typer.Exit(code=1) - - -def require_ok(response: dict[str, Any]) -> dict[str, Any]: - if not response.get("ok"): - console.print(f"[red]{response.get('error', 'Admin command failed.')}[/red]") - raise typer.Exit(code=1) - return response - - -def print_startup_banner(config: ServerConfig) -> None: - """Print a friendly startup banner.""" - description_line = "" - if config.server_description: - description_line = f" • Description: {config.server_description}\n" - - banner = f""" -================================================================================ - {config.server_name} - Commonwealth Online Consumer Server -================================================================================ - -Server Configuration: - • Binding to {config.host}:{config.port} -{description_line} • Max players: {config.max_players} - • Discovery port (UDP): 7778 - • Admin port (localhost): {config.admin_port} - -Connection Instructions: - • Local: 127.0.0.1:{config.port} - • LAN: :{config.port} - • Remote: Forward port {config.port}/TCP on your router - -Interactive Commands (type at the commonwealth> prompt): - • help List commands - • status Server status - • users Live player table (Enter to stop) - • users once Single snapshot - • kick PLAYER_ID Disconnect a player - • ban PLAYER_ID_OR_IP Ban a player or IP - • unban IP Remove an IP ban - • bans List bans - • world time HHmm Set time - • world weather ID Set weather - • quit Stop the server - -Note: - • Live position/state spam is off by default (log_verbosity=info). - • Use "users" for a live table; "users once" for a single snapshot. - • Set log_verbosity to "debug" in config for per-packet logs. - -Shutdown: - • Type quit / exit, or press Ctrl+C -================================================================================ -""" - print(banner) - - -@app.command("help") -def help_command() -> None: - """List available CLI commands.""" - _print_help_table() - - -def _print_help_table(*, interactive: bool = False) -> None: - title = ( - "Interactive Server Commands" - if interactive - else "Commonwealth Online Server Commands" - ) - table = Table(title=title, box=box.ROUNDED) - table.add_column("Command", style="cyan", no_wrap=True) - table.add_column("Description", style="bright_white") - - if interactive: - commands = [ - ("help", "List available commands"), - ("status", "Show server status and packet stats"), - ("users", "Live player table (positions/state); Enter to stop"), - ("users once", "Single snapshot of connected users"), - ("clients", "Same as users"), - ("kick PLAYER_ID [--reason TEXT]", "Disconnect a player without banning"), - ("ban PLAYER_ID_OR_IP [--reason TEXT]", "Ban a player id or IP address"), - ("unban IP", "Remove an IP from the ban list"), - ("bans", "List banned IP addresses"), - ("world time HHmm", "Set server time (e.g. 1430)"), - ("world weather FORM_ID", "Set weather (8-digit hex form id)"), - ("quit / exit / stop", "Stop the server and close this window session"), - ] - footer = ( - "Type commands at the commonwealth> prompt in this window. " - "Example: users | ban 2 --reason griefing" - ) - else: - commands = [ - ("help", "List available CLI commands"), - ("serve [--config PATH] [--interactive]", "Start the relay server"), - ("status", "Show server status and packet stats"), - ("users [--watch]", "List users; --watch for live updates"), - ("clients [--watch]", "Same as users"), - ("kick PLAYER_ID [--reason TEXT]", "Disconnect a player without banning"), - ("ban PLAYER_ID_OR_IP [--reason TEXT]", "Ban a player id or IP address"), - ("unban IP", "Remove an IP from the ban list"), - ("bans", "List banned IP addresses"), - ("world time HHmm", "Set server time (e.g. 1430)"), - ("world weather FORM_ID", "Set weather (8-digit hex form id)"), - ("config init [OUTPUT_PATH]", "Create a default config file"), - ] - footer = ( - "start.bat / start.sh launch an interactive prompt in the same window. " - "You can also run management commands from another terminal against admin_port." - ) - - for command, description in commands: - table.add_row(command, description) - - console.print(table) - console.print(f"\n[dim]{footer}[/dim]") - - -@app.command() -def serve( - config_path: Optional[str] = typer.Argument( - None, - help="Optional path to config.json (same as --config)", - ), - config: Optional[str] = typer.Option( - None, - "--config", - "-c", - help="Path to config.json file", - ), - host: Optional[str] = typer.Option( - None, - "--host", - "-H", - help="Server bind address (overrides config)", - ), - port: Optional[int] = typer.Option( - None, - "--port", - "-p", - help="Server port (overrides config)", - ), - interactive: bool = typer.Option( - False, - "--interactive", - "-i", - help="Start an interactive command prompt in this window (used by start.bat / start.sh)", - ), -) -> None: - """Start the Commonwealth Online relay server.""" - _require_python_version() - service: Optional[ServerService] = None - _shutdown_requested.clear() - - # Accept either `serve --config FILE` or `serve FILE` (file managers may pass FILE). - if config and config_path and Path(config).resolve() != Path(config_path).resolve(): - console.print( - "[red]Error: Conflicting config paths from positional argument and --config.[/red]" - ) - raise typer.Exit(code=1) - config = config or config_path - - previous_sigint = signal.getsignal(signal.SIGINT) - previous_sigterm = signal.getsignal(signal.SIGTERM) if hasattr(signal, "SIGTERM") else None - try: - signal.signal(signal.SIGINT, _request_shutdown) - if hasattr(signal, "SIGTERM"): - signal.signal(signal.SIGTERM, _request_shutdown) - - # Load or create config - if config: - try: - cfg = load_config(config) - except FileNotFoundError: - console.print(f"[red]Error: Config file not found: {config}[/red]") - raise typer.Exit(code=1) - except json.JSONDecodeError as e: - console.print(f"[red]Error: Invalid JSON in config file: {e}[/red]") - raise typer.Exit(code=1) - except ValueError as e: - console.print(f"[red]Error: Invalid config values: {e}[/red]") - raise typer.Exit(code=1) - bans_path = Path(config).resolve().parent / "bans.json" - else: - cfg = Config() - bans_path = Path(__file__).resolve().parent / "bans.json" - - # Apply CLI overrides - if host: - cfg.host = host - if port: - cfg.port = port - - # Validate config - is_valid, errors = validate_config(cfg) - if not is_valid: - console.print("[red]Configuration validation failed:[/red]") - for error in errors: - console.print(f" • {error}") - raise typer.Exit(code=1) - - try: - ensure_writable_directory(bans_path.parent) - except OSError as error: - console.print( - f"[red]ERROR: Cannot write server state in {bans_path.parent}: {error}[/red]" - ) - raise typer.Exit(code=1) - - # Create and configure service - server_config = ServerConfig( - host=cfg.host, - port=cfg.port, - server_name=cfg.server_name, - server_description=cfg.server_description, - max_players=cfg.max_players, - log_verbosity=cfg.log_verbosity, - admin_port=cfg.admin_port, - bans_path=str(bans_path), - ) - - service = get_service() - service.config = server_config - service.add_log_listener(log_callback) - - # Print startup banner - print_startup_banner(server_config) - - use_interactive = bool(interactive and sys.stdin.isatty() and sys.stdout.isatty()) - if interactive and not use_interactive: - console.print( - "[yellow]stdin/stdout are not a terminal; starting non-interactive mode. " - "Use the admin CLI against 127.0.0.1 to manage the server.[/yellow]" - ) - - console.print("[yellow]Starting server...[/yellow]") - if use_interactive: - service.start() - if not _wait_for_server_ready(service, timeout_seconds=5.0): - console.print("[red]Server failed to become ready.[/red]") - raise typer.Exit(code=1) - console.print( - "[green]Server running. Type [bold]help[/bold] for commands, " - "[bold]quit[/bold] to stop.[/green]\n" - ) - _run_interactive_shell(admin_port=server_config.admin_port) - console.print("\n[yellow]Stopping server...[/yellow]") - service.stop() - console.print("[green]Server stopped gracefully.[/green]") - else: - # Non-interactive mode for Host GUI / systemd / headless hosting. - service.serve_forever() - if _shutdown_requested.is_set(): - console.print("[green]Server stopped gracefully.[/green]") - - except KeyboardInterrupt: - console.print("\n[yellow]Shutdown signal received. Stopping server...[/yellow]") - if service is None: - service = get_service() - service.stop() - console.print("[green]Server stopped gracefully.[/green]") - except OSError as e: - console.print(f"[red]ERROR: {e}[/red]") - if service is not None: - try: - service.stop() - except Exception: - pass - raise typer.Exit(code=1) - except Exception as e: - console.print(f"[red]Fatal error: {e}[/red]") - if service is not None: - try: - service.stop() - except Exception: - pass - raise typer.Exit(code=1) - finally: - try: - signal.signal(signal.SIGINT, previous_sigint) - if previous_sigterm is not None and hasattr(signal, "SIGTERM"): - signal.signal(signal.SIGTERM, previous_sigterm) - except Exception: - pass - - -@app.command() -def status( - json_output: bool = typer.Option( - False, - "--json", - "-j", - help="Output as JSON", - ), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """Display server status and statistics.""" - response = require_ok(admin_request({"cmd": "stats"}, admin_port=admin_port)) - stats = response.get("data") or {} - - if json_output: - print(json.dumps(stats, indent=2)) - return - - _print_status_panel(stats) - - -def _format_bool_flag(value: Any) -> str: - if value is True: - return "Y" - if value is False: - return "N" - return "-" - - -def _format_coord(value: Any) -> str: - try: - return f"{float(value):.1f}" - except (TypeError, ValueError): - return "-" - - -def _build_users_table(client_rows: list[dict[str, Any]], *, title: str) -> RenderableType: - if not client_rows: - return Text("No clients currently connected.", style="dim") - - table = Table(title=title, box=box.ROUNDED) - table.add_column("ID", style="cyan", justify="right") - table.add_column("Address", style="bright_white") - table.add_column("X", justify="right") - table.add_column("Y", justify="right") - table.add_column("Z", justify="right") - table.add_column("Angle", justify="right") - table.add_column("Cell") - table.add_column("World") - table.add_column("Moving", justify="center") - table.add_column("Speed", justify="right") - table.add_column("Sprint", justify="center") - table.add_column("Sneak", justify="center") - table.add_column("Jump", justify="center") - table.add_column("Drawn", justify="center") - table.add_column("Type") - table.add_column("Connected", style="green") - - for client in client_rows: - connected_at = client.get("connected_at") - connected_time = ( - datetime.fromtimestamp(connected_at).strftime("%H:%M:%S") - if isinstance(connected_at, (int, float)) - else "?" - ) - transform = client.get("last_transform") - if not isinstance(transform, dict): - transform = {} - - speed = transform.get("movementSpeed") - try: - speed_text = f"{float(speed):.1f}" if speed is not None else "-" - except (TypeError, ValueError): - speed_text = "-" - - table.add_row( - str(client.get("player_id", "?")), - str(client.get("address", "?")), - _format_coord(transform.get("x")), - _format_coord(transform.get("y")), - _format_coord(transform.get("z")), - _format_coord(transform.get("angleZ")), - str(transform.get("cellId") or "-"), - str(transform.get("worldspaceId") or "-"), - _format_bool_flag(transform.get("isMoving")), - speed_text, - _format_bool_flag(transform.get("isSprinting")), - _format_bool_flag(transform.get("isSneaking")), - _format_bool_flag(transform.get("isJumping")), - _format_bool_flag(transform.get("weaponDrawn")), - str(transform.get("movementType") or "-"), - connected_time, - ) - - return table - - -def _print_users_table(client_rows: list[dict[str, Any]], *, title: str) -> None: - console.print(_build_users_table(client_rows, title=title)) - - -def _fetch_users_rows(*, admin_port: int, command: str = "users") -> list[dict[str, Any]] | None: - response = _shell_admin_request({"cmd": command}, admin_port=admin_port) - if response is None: - return None - data = response.get("data") or {} - rows = data.get("clients") or [] - return rows if isinstance(rows, list) else [] - - -def _watch_users_table( - *, - admin_port: int, - title: str, - command: str = "users", - mute_server_logs: bool = False, -) -> None: - """Refresh the users table until Enter or Ctrl+C.""" - stop = threading.Event() - service = get_service() if mute_server_logs else None - muted = False - if service is not None: - try: - service.remove_log_listener(log_callback) - muted = True - except Exception: - muted = False - - def wait_for_stop() -> None: - try: - input() - except (EOFError, KeyboardInterrupt): - pass - stop.set() - - waiter = threading.Thread(target=wait_for_stop, daemon=True) - waiter.start() - - def render() -> RenderableType: - rows = _fetch_users_rows(admin_port=admin_port, command=command) - if rows is None: - body: RenderableType = Text("Could not refresh users.", style="red") - else: - body = _build_users_table(rows, title=title) - footer = Text("Live updating every 1s — press Enter to stop.", style="dim") - return Group(body, Text(""), footer) - - try: - with Live( - render(), - console=console, - refresh_per_second=4, - vertical_overflow="visible", - ) as live: - while not stop.is_set(): - live.update(render()) - stop.wait(1.0) - finally: - if muted and service is not None: - service.add_log_listener(log_callback) - - -@app.command() -def clients( - json_output: bool = typer.Option( - False, - "--json", - "-j", - help="Output as JSON", - ), - watch: bool = typer.Option( - False, - "--watch", - "-w", - help="Live-update the table until Enter is pressed", - ), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """List connected clients with positions and movement state.""" - if json_output and watch: - console.print("[red]Error: --json cannot be combined with --watch.[/red]") - raise typer.Exit(code=1) - - if watch: - _watch_users_table( - admin_port=admin_port, - title="Connected Clients", - command="clients", - ) - return - - response = require_ok(admin_request({"cmd": "clients"}, admin_port=admin_port)) - data = response.get("data") or {} - client_rows = data.get("clients") or [] - - if json_output: - print(json.dumps(data, indent=2)) - return - - _print_users_table(client_rows, title="Connected Clients") - - -@app.command() -def users( - json_output: bool = typer.Option( - False, - "--json", - "-j", - help="Output as JSON", - ), - watch: bool = typer.Option( - False, - "--watch", - "-w", - help="Live-update the table until Enter is pressed", - ), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """List connected users with positions and movement state (same as clients).""" - if json_output and watch: - console.print("[red]Error: --json cannot be combined with --watch.[/red]") - raise typer.Exit(code=1) - - if watch: - _watch_users_table( - admin_port=admin_port, - title="Connected Users", - command="users", - ) - return - - response = require_ok(admin_request({"cmd": "users"}, admin_port=admin_port)) - data = response.get("data") or {} - client_rows = data.get("clients") or [] - - if json_output: - print(json.dumps(data, indent=2)) - return - - _print_users_table(client_rows, title="Connected Users") - - -@app.command() -def kick( - player_id: int = typer.Argument(..., help="Connected player id to kick"), - reason: str = typer.Option("", "--reason", "-r", help="Optional kick reason"), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """Disconnect a connected player without banning their IP.""" - response = require_ok( - admin_request( - {"cmd": "kick", "playerId": player_id, "reason": reason}, - admin_port=admin_port, - ) - ) - console.print(f"[green]{response.get('message', 'Player kicked.')}[/green]") - - -@app.command() -def ban( - target: str = typer.Argument(..., help="Player id or IPv4 address to ban"), - reason: str = typer.Option("", "--reason", "-r", help="Optional ban reason"), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """Ban a connected player (by id) or an IP address, disconnecting matching sessions.""" - request: dict[str, Any] = {"cmd": "ban", "reason": reason} - if looks_like_ipv4(target): - request["ip"] = target.strip() - else: - try: - request["playerId"] = int(target) - except ValueError: - console.print( - f"[red]Error: ban target must be a player id or IPv4 address. Got: {target}[/red]" - ) - raise typer.Exit(code=1) - - response = require_ok(admin_request(request, admin_port=admin_port)) - console.print(f"[green]{response.get('message', 'Ban applied.')}[/green]") - - -@app.command() -def unban( - ip: str = typer.Argument(..., help="IPv4 address to remove from the ban list"), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """Remove an IP address from the ban list.""" - response = require_ok( - admin_request({"cmd": "unban", "ip": ip}, admin_port=admin_port) - ) - console.print(f"[green]{response.get('message', 'IP unbanned.')}[/green]") - - -@app.command() -def bans( - json_output: bool = typer.Option( - False, - "--json", - "-j", - help="Output as JSON", - ), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """List currently banned IP addresses.""" - response = require_ok(admin_request({"cmd": "bans"}, admin_port=admin_port)) - data = response.get("data") or {} - ban_rows = data.get("bans") or [] - - if json_output: - print(json.dumps(data, indent=2)) - return - - _print_bans_table(ban_rows) - - -world_app = typer.Typer(help="Manage world state (time, weather)") - - -@world_app.command("time") -def world_time( - hhmm: str = typer.Argument(..., help="Time in HHmm format (e.g., 1430 for 14:30)"), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """Set server time (broadcast to all clients).""" - if not (len(hhmm) <= 4 and hhmm.isdigit()): - console.print(f"[red]Error: Time must be in HHmm format (e.g., 1430). Got: {hhmm}[/red]") - raise typer.Exit(code=1) - - response = require_ok( - admin_request({"cmd": "world_time", "hhmm": hhmm}, admin_port=admin_port) - ) - console.print(f"[green]{response.get('message', 'Server time updated.')}[/green]") - - -@world_app.command("weather") -def world_weather( - form_id: str = typer.Argument(..., help="8-digit hex form ID (e.g., 0002b52a)"), - admin_port: int = typer.Option( - CONFIG_DEFAULT_ADMIN_PORT, - "--admin-port", - help="Localhost admin control port of the running server", - ), -) -> None: - """Set server weather (broadcast to all clients).""" - response = require_ok( - admin_request({"cmd": "world_weather", "weather": form_id}, admin_port=admin_port) - ) - console.print(f"[green]{response.get('message', 'Server weather updated.')}[/green]") - - -app.add_typer(world_app, name="world") - - -config_app = typer.Typer(help="Manage server configuration") - - -@config_app.command("init") -def config_init( - output_path: str = typer.Argument( - "commonwealth-server.json", - help="Path where config file will be created", - ), -) -> None: - """Generate a default configuration file.""" - path = Path(output_path) - - if path.exists(): - console.print(f"[yellow]File already exists: {output_path}[/yellow]") - if typer.confirm("Overwrite?"): - generate_default_config(str(path)) - console.print(f"[green]Config written to {output_path}[/green]") - else: - console.print("[dim]Cancelled.[/dim]") - else: - generate_default_config(str(path)) - console.print(f"[green]Config written to {output_path}[/green]") - console.print(f"\n[yellow]To start the server with this config:[/yellow]") - console.print(f" commonwealth serve --config {output_path}") - - -app.add_typer(config_app, name="config") - - -def _format_uptime(seconds: float) -> str: - """Format uptime as human-readable string.""" - if seconds < 60: - return f"{int(seconds)}s" - elif seconds < 3600: - minutes = int(seconds / 60) - secs = int(seconds % 60) - return f"{minutes}m {secs}s" - else: - hours = int(seconds / 3600) - minutes = int((seconds % 3600) / 60) - return f"{hours}h {minutes}m" - - -def _wait_for_server_ready(service: ServerService, *, timeout_seconds: float) -> bool: - deadline = time.time() + timeout_seconds - while time.time() < deadline: - if service.is_running(): - return True - time.sleep(0.05) - return service.is_running() - - -def _shell_admin_request( - request: dict[str, Any], - *, - admin_port: int, -) -> dict[str, Any] | None: - """Admin request for interactive mode; prints errors and returns None on failure.""" - try: - response = send_admin_command(request, port=admin_port) - except OSError: - console.print( - f"[red]Error: Could not reach admin port 127.0.0.1:{admin_port}. " - "Is the server still running?[/red]" - ) - return None - except (ConnectionError, ValueError, json.JSONDecodeError) as error: - console.print(f"[red]Error talking to admin port: {error}[/red]") - return None - - if not response.get("ok"): - console.print(f"[red]{response.get('error', 'Admin command failed.')}[/red]") - return None - return response - - -def _parse_reason_option(args: list[str]) -> tuple[list[str], str]: - """Pull --reason/-r from args. Returns (remaining_args, reason).""" - remaining: list[str] = [] - reason = "" - index = 0 - while index < len(args): - token = args[index] - if token in ("--reason", "-r"): - if index + 1 >= len(args): - console.print("[red]Error: --reason requires a value.[/red]") - return [], "" - reason = args[index + 1] - index += 2 - continue - if token.startswith("--reason="): - reason = token.split("=", 1)[1] - index += 1 - continue - remaining.append(token) - index += 1 - return remaining, reason - - -def _print_status_panel(stats: dict[str, Any]) -> None: - status_text = "[green]RUNNING[/green]" if stats.get("is_running") else "[red]STOPPED[/red]" - uptime_text = _format_uptime(float(stats.get("uptime_seconds") or 0.0)) - info_panel = f""" -[bold]Server Status[/bold] - -Status: {status_text} -Address: [bright_white]{stats.get('host')}:{stats.get('port')}[/bright_white] -Uptime: [bold]{uptime_text}[/bold] -Clients: [bold]{stats.get('connected_clients', 0)}[/bold] - -[bold]Packet Statistics[/bold] - -Transform Packets: Received {stats.get('transform_packets_received', 0):,} | Broadcast {stats.get('transform_packets_broadcast', 0):,} -WorldState Packets: Received {stats.get('world_state_packets_received', 0):,} | Broadcast {stats.get('world_state_packets_broadcast', 0):,} -Total Packets: Received {stats.get('packets_received', 0):,} | Sent {stats.get('packets_sent', 0):,} -""" - console.print(Panel(info_panel.strip(), border_style="cyan", box=box.ROUNDED)) - - -def _print_bans_table(ban_rows: list[dict[str, Any]]) -> None: - if not ban_rows: - console.print("[dim]No banned IPs.[/dim]") - return - - table = Table(title="Banned IPs", box=box.ROUNDED) - table.add_column("IP", style="bright_white") - table.add_column("Reason", style="yellow") - table.add_column("Banned At", style="green") - - for entry in ban_rows: - banned_at = entry.get("bannedAt") - banned_text = ( - datetime.fromtimestamp(banned_at).strftime("%Y-%m-%d %H:%M:%S") - if banned_at - else "?" - ) - table.add_row( - str(entry.get("ip")), - str(entry.get("reason") or ""), - banned_text, - ) - - console.print(table) - - -def _dispatch_interactive_command(args: list[str], *, admin_port: int) -> bool: - """ - Handle one interactive command. - - Returns False when the shell should exit. - """ - if not args: - return True - - command = args[0].lower() - rest = args[1:] - - if command in ("quit", "exit", "stop", "q"): - return False - - if command == "help": - _print_help_table(interactive=True) - return True - - if command == "status": - response = _shell_admin_request({"cmd": "stats"}, admin_port=admin_port) - if response is not None: - _print_status_panel(response.get("data") or {}) - return True - - if command in ("users", "clients"): - title = "Connected Users" if command == "users" else "Connected Clients" - once = bool(rest) and rest[0].lower() in ("once", "snapshot", "--once") - if once: - response = _shell_admin_request({"cmd": command}, admin_port=admin_port) - if response is not None: - data = response.get("data") or {} - _print_users_table(data.get("clients") or [], title=title) - return True - - _watch_users_table( - admin_port=admin_port, - title=title, - command=command, - mute_server_logs=True, - ) - return True - - if command == "kick": - rest, reason = _parse_reason_option(rest) - if len(rest) != 1: - console.print("[red]Usage: kick PLAYER_ID [--reason TEXT][/red]") - return True - try: - player_id = int(rest[0]) - except ValueError: - console.print(f"[red]Error: player id must be an integer. Got: {rest[0]}[/red]") - return True - response = _shell_admin_request( - {"cmd": "kick", "playerId": player_id, "reason": reason}, - admin_port=admin_port, - ) - if response is not None: - console.print(f"[green]{response.get('message', 'Player kicked.')}[/green]") - return True - - if command == "ban": - rest, reason = _parse_reason_option(rest) - if len(rest) != 1: - console.print("[red]Usage: ban PLAYER_ID_OR_IP [--reason TEXT][/red]") - return True - target = rest[0] - request: dict[str, Any] = {"cmd": "ban", "reason": reason} - if looks_like_ipv4(target): - request["ip"] = target.strip() - else: - try: - request["playerId"] = int(target) - except ValueError: - console.print( - f"[red]Error: ban target must be a player id or IPv4 address. Got: {target}[/red]" - ) - return True - response = _shell_admin_request(request, admin_port=admin_port) - if response is not None: - console.print(f"[green]{response.get('message', 'Ban applied.')}[/green]") - return True - - if command == "unban": - if len(rest) != 1: - console.print("[red]Usage: unban IP[/red]") - return True - response = _shell_admin_request({"cmd": "unban", "ip": rest[0]}, admin_port=admin_port) - if response is not None: - console.print(f"[green]{response.get('message', 'IP unbanned.')}[/green]") - return True - - if command == "bans": - response = _shell_admin_request({"cmd": "bans"}, admin_port=admin_port) - if response is not None: - data = response.get("data") or {} - _print_bans_table(data.get("bans") or []) - return True - - if command == "world": - if len(rest) < 2: - console.print("[red]Usage: world time HHmm | world weather FORM_ID[/red]") - return True - subcommand = rest[0].lower() - if subcommand == "time": - hhmm = rest[1] - if not (len(hhmm) <= 4 and hhmm.isdigit()): - console.print( - f"[red]Error: Time must be in HHmm format (e.g., 1430). Got: {hhmm}[/red]" - ) - return True - response = _shell_admin_request( - {"cmd": "world_time", "hhmm": hhmm}, - admin_port=admin_port, - ) - if response is not None: - console.print( - f"[green]{response.get('message', 'Server time updated.')}[/green]" - ) - return True - if subcommand == "weather": - response = _shell_admin_request( - {"cmd": "world_weather", "weather": rest[1]}, - admin_port=admin_port, - ) - if response is not None: - console.print( - f"[green]{response.get('message', 'Server weather updated.')}[/green]" - ) - return True - console.print("[red]Usage: world time HHmm | world weather FORM_ID[/red]") - return True - - console.print(f"[red]Unknown command: {command}[/red]") - console.print("[dim]Type help to list commands.[/dim]") - return True - - -def _run_interactive_shell(*, admin_port: int) -> None: - """Read management commands from stdin until quit/exit/Ctrl+C.""" - if not sys.stdin.isatty(): - console.print( - "[yellow]Interactive prompt requires a terminal. " - "Server remains available via the admin port.[/yellow]" - ) - while not _shutdown_requested.is_set(): - time.sleep(0.5) - return - - while not _shutdown_requested.is_set(): - try: - line = input("commonwealth> ") - except EOFError: - console.print() - break - except KeyboardInterrupt: - console.print() - break - - line = line.strip() - if not line: - continue - - try: - args = shlex.split(line, posix=(os.name != "nt")) - except ValueError as error: - console.print(f"[red]Could not parse command: {error}[/red]") - continue - - if not _dispatch_interactive_command(args, admin_port=admin_port): - break - - -if __name__ == "__main__": - app() diff --git a/server/dedicated_server.py b/server/dedicated_server.py deleted file mode 100644 index 9d23c4e..0000000 --- a/server/dedicated_server.py +++ /dev/null @@ -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()) diff --git a/server/dev_server_app.py b/server/dev_server_app.py deleted file mode 100644 index 8664cc1..0000000 --- a/server/dev_server_app.py +++ /dev/null @@ -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()) diff --git a/server/fake_client.py b/server/fake_client.py deleted file mode 100644 index 4e46d4b..0000000 --- a/server/fake_client.py +++ /dev/null @@ -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 "" - - 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 ''}" for item in equipped_items) - if equipped_items - else "" - ) - appearance = player.get("appearance") - appearance_text = ( - ( - f"version={appearance.get('version')}, race={appearance.get('raceFormId') or ''}, " - f"isFemale={appearance.get('isFemale', '')}, " - 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 "" - ) - 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 "" - ) - - 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 ''}", - ( - "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 ''}" for item in player.get("equippedItems", [])) - if player.get("equippedItems") - else "" - ) - appearance = player.get("appearance") - appearance_text = ( - f"race={appearance.get('raceFormId') or ''}, isFemale={appearance.get('isFemale', '')}, 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 "" - ) - 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}") diff --git a/server/fake_player.py b/server/fake_player.py deleted file mode 100644 index 61faac6..0000000 --- a/server/fake_player.py +++ /dev/null @@ -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}") diff --git a/server/fix-port.bat b/server/fix-port.bat index 48ce8f6..eed5f39 100644 --- a/server/fix-port.bat +++ b/server/fix-port.bat @@ -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% diff --git a/server/fix-port.sh b/server/fix-port.sh old mode 100644 new mode 100755 index ff97873..370fab7 --- a/server/fix-port.sh +++ b/server/fix-port.sh @@ -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" - <&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 diff --git a/server/gns_gameplay_adapter.py b/server/gns_gameplay_adapter.py deleted file mode 100644 index 64e58e0..0000000 --- a/server/gns_gameplay_adapter.py +++ /dev/null @@ -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() diff --git a/server/gns_snapshot_envelope.py b/server/gns_snapshot_envelope.py deleted file mode 100644 index 20906a0..0000000 --- a/server/gns_snapshot_envelope.py +++ /dev/null @@ -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) diff --git a/server/gns_transport.py b/server/gns_transport.py deleted file mode 100644 index 4292126..0000000 --- a/server/gns_transport.py +++ /dev/null @@ -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 diff --git a/server/lan_discovery.py b/server/lan_discovery.py deleted file mode 100644 index 1eefaba..0000000 --- a/server/lan_discovery.py +++ /dev/null @@ -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 diff --git a/server/npc_authority.py b/server/npc_authority.py deleted file mode 100644 index bdc377f..0000000 --- a/server/npc_authority.py +++ /dev/null @@ -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 "") diff --git a/server/packet_codec.py b/server/packet_codec.py deleted file mode 100644 index 1bf0c4a..0000000 --- a/server/packet_codec.py +++ /dev/null @@ -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 diff --git a/server/player_state.py b/server/player_state.py deleted file mode 100644 index f7fb210..0000000 --- a/server/player_state.py +++ /dev/null @@ -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 diff --git a/server/protocol_v2_client.py b/server/protocol_v2_client.py deleted file mode 100644 index 32e24a0..0000000 --- a/server/protocol_v2_client.py +++ /dev/null @@ -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()) diff --git a/server/requirements-host-gui.txt b/server/requirements-host-gui.txt deleted file mode 100644 index 4a54271..0000000 --- a/server/requirements-host-gui.txt +++ /dev/null @@ -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 diff --git a/server/requirements-server.txt b/server/requirements-server.txt deleted file mode 100644 index 7908a87..0000000 --- a/server/requirements-server.txt +++ /dev/null @@ -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 diff --git a/server/requirements.txt b/server/requirements.txt deleted file mode 100644 index 6480c6b..0000000 --- a/server/requirements.txt +++ /dev/null @@ -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 diff --git a/server/scripts/linux_compat_checks.sh b/server/scripts/linux_compat_checks.sh old mode 100644 new mode 100755 index d0a88f7..dfdcc8b --- a/server/scripts/linux_compat_checks.sh +++ b/server/scripts/linux_compat_checks.sh @@ -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." diff --git a/server/server.py b/server/server.py deleted file mode 100644 index e661b7d..0000000 --- a/server/server.py +++ /dev/null @@ -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() diff --git a/server/server_core.py b/server/server_core.py deleted file mode 100644 index bce703d..0000000 --- a/server/server_core.py +++ /dev/null @@ -1,1461 +0,0 @@ -from __future__ import annotations - -import json -import math -import socket -import sys -import threading -import time -from collections import deque -from collections.abc import Callable -from typing import Any - -from ban_store import BanStore -from client_session import ClientSession -from lan_discovery import DISCOVERY_PORT, LanDiscoveryResponder -from npc_authority import NpcAuthorityManager, ScopeKey, scope_from_transform -from player_state import normalize_player_state_packet -from world_state_presets import normalize_fw_console_arg, relay_weather_form_id - -HOST = "0.0.0.0" -PORT = 7777 -ACCEPT_TIMEOUT_SECONDS = 0.5 -DEFAULT_SERVER_NAME = "Commonwealth Online Server" -DEFAULT_MAX_PLAYERS = 16 -DEFAULT_LOG_VERBOSITY = "info" -SESSION_ENDED_BANNED = "banned" -SESSION_ENDED_KICKED = "kicked" -SESSION_ENDED_FULL = "server_full" -SESSION_ENDED_PROTOCOL = "protocol_mismatch" -SESSION_ENDED_RATE_LIMITED = "rate_limited" -SESSION_ENDED_PACKET_TOO_LARGE = "packet_too_large" - -PROTOCOL_VERSION = 2 -LEGACY_PROTOCOL_VERSION = 1 -MAX_PACKET_CHARS = 64 * 1024 -CLIENT_IDLE_TIMEOUT_SECONDS = 60.0 -CLIENT_HANDSHAKE_TIMEOUT_SECONDS = 10.0 -MAX_ABS_COORDINATE = 10_000_000.0 -MAX_MOVEMENT_SPEED = 100_000.0 -MAX_ACTION_EVENTS = 16 -MAX_NPCS_PER_PACKET = 64 -MAX_PACKETS_PER_SECOND = 120 -MAX_CONNECT_ATTEMPTS = 8 -CONNECT_ATTEMPT_WINDOW_SECONDS = 10.0 -EXTERIOR_INTEREST_RADIUS = 8192.0 -MAX_NORMAL_MOVEMENT_SPEED = 2500.0 -MOVEMENT_GRACE_DISTANCE = 512.0 -MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS = 5.0 -MOVEMENT_TRANSITION_TYPES = frozenset({ - "teleport", - "cell_change", - "worldspace_change", - "load", - "spawn", - "fast_travel", -}) -ALLOWED_MOVEMENT_TYPES = MOVEMENT_TRANSITION_TYPES | {"normal"} - -_LOG_LEVELS = { - "debug": 10, - "info": 20, - "warning": 30, - "error": 40, -} - - -def _bounded_repr(value: Any, max_chars: int = 256) -> str: - text = repr(value) - return text if len(text) <= max_chars else text[:max_chars] + "..." - - -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 _is_hex_form_id(value: Any, *, allow_empty: bool = False, allow_zero: bool = True) -> bool: - if not isinstance(value, str): - return False - if allow_empty and value == "": - return True - if not 1 <= len(value) <= 8: - return False - if any(character not in "0123456789abcdefABCDEF" for character in value): - return False - parsed = int(value, 16) - return allow_zero or parsed != 0 - - -def _normalize_hex_form_id(value: str) -> str: - return value.upper().zfill(8) - - -def _normalize_action_events(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - allowed = { - (1, "meleeattackStart"), - (2, "meleeattackStart"), - (3, "fireSingle"), - } - normalized: list[dict[str, Any]] = [] - for item in value[:MAX_ACTION_EVENTS]: - if not isinstance(item, dict): - continue - sequence = item.get("sequence") - action_type = item.get("type") - event_name = item.get("eventName") - if not _is_int(sequence, 1) or not _is_int(action_type, 1, 3) or not isinstance(event_name, str): - continue - if (action_type, event_name) not in allowed: - continue - clean: dict[str, Any] = { - "sequence": sequence, - "type": action_type, - "eventName": event_name, - } - for name in ("actorStateFlags1", "actorStateFlags2"): - if _is_int(item.get(name)): - clean[name] = item[name] - normalized.append(clean) - return normalized - - -def _scope_from_state(state: dict[str, Any] | None) -> tuple[str, str, float, float] | None: - if not isinstance(state, dict): - return None - cell_id = state.get("cellId") - worldspace_id = state.get("worldspaceId", "") - x = state.get("x") - y = state.get("y") - if not _is_hex_form_id(cell_id, allow_zero=False): - return None - if not _is_hex_form_id(worldspace_id, allow_empty=True, allow_zero=True): - return None - if not _is_finite_number(x, -MAX_ABS_COORDINATE, MAX_ABS_COORDINATE): - return None - if not _is_finite_number(y, -MAX_ABS_COORDINATE, MAX_ABS_COORDINATE): - return None - return ( - _normalize_hex_form_id(cell_id), - _normalize_hex_form_id(worldspace_id) if worldspace_id else "", - float(x), - float(y), - ) - - -def states_share_interest(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: - a_scope = _scope_from_state(a) - b_scope = _scope_from_state(b) - if a_scope is None or b_scope is None: - return True - a_cell, a_world, ax, ay = a_scope - b_cell, b_world, bx, by = b_scope - if a_cell == b_cell: - return True - if not a_world or a_world != b_world: - return False - return math.hypot(ax - bx, ay - by) <= EXTERIOR_INTEREST_RADIUS - - -def get_lan_addresses() -> list[str]: - addresses: list[str] = [] - try: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: - probe.connect(("8.8.8.8", 80)) - primary = probe.getsockname()[0] - if primary and not primary.startswith("127."): - addresses.append(primary) - except OSError: - pass - try: - hostname = socket.gethostname() - for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET): - candidate = info[4][0] - if candidate and not candidate.startswith("127.") and candidate not in addresses: - addresses.append(candidate) - except OSError: - pass - return addresses - - -class FalloutTogetherServer: - def __init__( - self, - host: str = HOST, - port: int = PORT, - server_name: str = DEFAULT_SERVER_NAME, - server_description: str = "", - max_players: int = DEFAULT_MAX_PLAYERS, - bans_path: str | None = None, - log_verbosity: str = DEFAULT_LOG_VERBOSITY, - ) -> None: - self.host = host - self.port = port - self.server_name = server_name or DEFAULT_SERVER_NAME - self.server_description = server_description or "" - self.max_players = max_players if max_players >= 1 else DEFAULT_MAX_PLAYERS - verbosity = str(log_verbosity or DEFAULT_LOG_VERBOSITY).strip().lower() - self.log_verbosity = verbosity if verbosity in _LOG_LEVELS else DEFAULT_LOG_VERBOSITY - self._ban_store = BanStore(bans_path) - - self._lock = threading.RLock() - self._log_lock = threading.RLock() - self._clients: dict[socket.socket, ClientSession] = {} - self._client_threads: set[threading.Thread] = set() - self._log_listeners: list[Callable[..., None]] = [] - self._server_socket: socket.socket | None = None - self._accept_thread: threading.Thread | None = None - self._discovery: LanDiscoveryResponder | None = None - self._running = False - self._started_at: float | None = None - self._next_player_id = 1 - self._world_state_host_player_id: int | None = None - self._server_world_state: dict[str, str] = {} - self._last_npc_state: dict[str, Any] | None = None - self._last_npc_state_by_scope: dict[ScopeKey, dict[str, Any]] = {} - self._last_player_state_by_player_id: dict[int, dict[str, Any]] = {} - self._npc_authority = NpcAuthorityManager() - self._connect_attempts: dict[str, deque[float]] = {} - - self._stats: dict[str, int] = { - "clientsConnected": 0, - "clientsDisconnected": 0, - "pendingConnectionsRejected": 0, - "packetsReceived": 0, - "packetsSent": 0, - "packetsBroadcast": 0, - "packetsRejected": 0, - "rateLimitedPackets": 0, - "transformPacketsReceived": 0, - "transformPacketsBroadcast": 0, - "transformPacketsInterestFiltered": 0, - "playerStatePacketsReceived": 0, - "playerStatePacketsBroadcast": 0, - "movementPacketsRejected": 0, - "movementCorrectionsSent": 0, - "worldStatePacketsReceived": 0, - "worldStatePacketsBroadcast": 0, - "npcStatePacketsReceived": 0, - "npcStatePacketsBroadcast": 0, - "npcAuthorityChanges": 0, - "npcAuthorityRejects": 0, - "combatHitsReceived": 0, - "combatHitsRouted": 0, - "worldStateHostPacketsBroadcast": 0, - "serverWorldStatePacketsBroadcast": 0, - "sessionEndedPacketsSent": 0, - "bannedConnectionsRejected": 0, - "disconnectPacketsBroadcast": 0, - "protocolV2Connections": 0, - "legacyConnections": 0, - } - - def start(self) -> None: - with self._lock: - if self._running: - return - self._prepare_server_socket() - self._start_discovery() - self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True) - self._accept_thread.start() - - def serve_forever(self) -> None: - with self._lock: - if self._running: - raise RuntimeError("Server is already running.") - self._prepare_server_socket() - self._start_discovery() - self._accept_loop() - - def stop(self) -> None: - with self._lock: - self._running = False - server_socket = self._server_socket - self._server_socket = None - clients = list(self._clients.values()) - client_threads = [t for t in self._client_threads if t is not threading.current_thread()] - - discovery = self._discovery - self._discovery = None - if discovery is not None: - discovery.stop() - if server_socket is not None: - try: - server_socket.close() - except OSError: - pass - for client in clients: - self._close_client_socket(client) - accept_thread = self._accept_thread - if accept_thread is not None and accept_thread is not threading.current_thread(): - accept_thread.join(timeout=1.0) - self._accept_thread = None - for thread in client_threads: - thread.join(timeout=1.0) - with self._lock: - self._client_threads.clear() - - def is_running(self) -> bool: - with self._lock: - return self._running - - def _active_clients_locked(self) -> list[ClientSession]: - return [client for client in self._clients.values() if client.gameplay_active] - - def get_clients(self) -> list[dict[str, Any]]: - with self._lock: - clients = self._active_clients_locked() - return [client.to_snapshot() for client in clients] - - def get_stats(self) -> dict[str, Any]: - with self._lock: - active_clients = self._active_clients_locked() - pending = len(self._clients) - len(active_clients) - started_at = self._started_at - stats = dict(self._stats) - stats.update( - { - "host": self.host, - "port": self.port, - "serverName": self.server_name, - "serverDescription": self.server_description, - "maxPlayers": self.max_players, - "lanAddresses": get_lan_addresses() if self._running and self.host == "0.0.0.0" else [], - "isRunning": self._running, - "startedAt": started_at, - "uptimeSeconds": time.time() - started_at if started_at is not None else 0.0, - "connectedClients": len(active_clients), - "pendingConnections": pending, - "nextPlayerId": self._next_player_id, - "protocolVersion": PROTOCOL_VERSION, - "npcAuthorityScopes": len(self._npc_authority.assignments()), - } - ) - return stats - - def get_server_world_state(self) -> dict[str, str]: - with self._lock: - return dict(self._server_world_state) - - def set_server_time(self, hhmm: str) -> bool: - text = str(hhmm).strip() - if not text.isdigit() or len(text) > 4: - self._log(f"Rejected invalid server time HHmm value: {hhmm!r}") - return False - normalized = text.zfill(4) - hours = int(normalized[:2]) - minutes = int(normalized[2:]) - if hours > 23 or minutes > 59: - self._log(f"Rejected out-of-range server time HHmm value: {hhmm!r}") - return False - with self._lock: - self._server_world_state["timeHHmm"] = normalized - self._broadcast_server_world_state() - return True - - def set_server_weather(self, fw_console_arg: str) -> bool: - try: - normalized_fw_arg = normalize_fw_console_arg(fw_console_arg) - except ValueError: - self._log(f"Rejected invalid server weather console id: {fw_console_arg!r}") - return False - with self._lock: - self._server_world_state["weatherConsoleArg"] = normalized_fw_arg - self._server_world_state["weatherFormId"] = relay_weather_form_id(normalized_fw_arg) - self._broadcast_server_world_state() - return True - - def list_bans(self) -> list[dict[str, Any]]: - return [ - {"ip": entry.ip, "reason": entry.reason, "bannedAt": entry.banned_at} - for entry in self._ban_store.list_bans() - ] - - def unban_ip(self, ip: str) -> bool: - normalized = str(ip).strip() - removed = self._ban_store.unban_ip(normalized) - if removed: - self._log(f"Unbanned IP {normalized}") - return removed - - def ban_ip(self, ip: str, reason: str = "") -> dict[str, Any]: - normalized = str(ip).strip() - if not normalized: - raise ValueError("IP address cannot be empty.") - entry = self._ban_store.ban_ip(normalized, reason=reason) - ended = self._end_sessions_for_ip(normalized, code=SESSION_ENDED_BANNED, reason=entry.reason) - self._log(f"Banned IP {entry.ip}" + (f" (reason: {entry.reason})" if entry.reason else "")) - return {"ip": entry.ip, "reason": entry.reason, "bannedAt": entry.banned_at, "sessionsEnded": ended} - - def ban_player(self, player_id: int, reason: str = "") -> dict[str, Any]: - client = self._find_client_by_player_id(player_id) - if client is None: - raise KeyError(f"No connected player with id {player_id}") - return self.ban_ip(client.address[0], reason=reason) - - def kick_player(self, player_id: int, reason: str = "") -> dict[str, Any]: - client = self._find_client_by_player_id(player_id) - if client is None: - raise KeyError(f"No connected player with id {player_id}") - ip = client.address[0] - self._end_client_session(client, code=SESSION_ENDED_KICKED, reason=reason) - self._log(f"Kicked player {player_id} ({client.label})" + (f" (reason: {reason})" if reason else "")) - return {"playerId": player_id, "ip": ip, "code": SESSION_ENDED_KICKED, "reason": str(reason or "")} - - def add_log_listener(self, callback: Callable[..., None]) -> None: - 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: - with self._log_lock: - if callback in self._log_listeners: - self._log_listeners.remove(callback) - - def _prepare_server_socket(self) -> None: - 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((self.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 server to {self.host}:{self.port}. The port may already be in use or unavailable. ({error})" - ) from error - - if self.port == 0: - self.port = int(server_socket.getsockname()[1]) - self._server_socket = server_socket - self._running = True - self._started_at = time.time() - self._next_player_id = 1 - self._world_state_host_player_id = None - self._last_npc_state = None - self._last_npc_state_by_scope.clear() - self._last_player_state_by_player_id.clear() - self._npc_authority.clear() - self._connect_attempts.clear() - - self._log(f"Commonwealth Online server listening on {self.host}:{self.port}") - self._log(f"Protocol v{PROTOCOL_VERSION} negotiation enabled; legacy clients remain temporarily compatible.") - if self.host == "0.0.0.0": - self._log("Bind address 0.0.0.0 means all interfaces; clients should not join 0.0.0.0.") - self._log(f"Local clients can connect at 127.0.0.1:{self.port}") - for address in get_lan_addresses(): - self._log(f"LAN clients can connect at {address}:{self.port}") - if sys.platform == "win32": - self._log(f"Allow TCP {self.port} through Windows Firewall and forward it only when hosting externally.") - else: - self._log(f"Allow TCP {self.port} through the host firewall; router forwarding is only needed externally.") - else: - self._log(f"Clients can connect at {self.host}:{self.port}") - - def _start_discovery(self) -> None: - discovery = LanDiscoveryResponder(self) - try: - discovery.start() - except OSError as error: - self._log(f"LAN discovery unavailable on UDP {DISCOVERY_PORT}: {error}. Direct connections still work.", level="warning") - self._discovery = None - return - self._discovery = discovery - self._log(f"LAN discovery listening on UDP port {DISCOVERY_PORT}") - - def _allow_connect_attempt(self, ip: str) -> bool: - now = time.monotonic() - cutoff = now - CONNECT_ATTEMPT_WINDOW_SECONDS - with self._lock: - attempts = self._connect_attempts.setdefault(ip, deque()) - while attempts and attempts[0] < cutoff: - attempts.popleft() - attempts.append(now) - return len(attempts) <= MAX_CONNECT_ATTEMPTS - - def _accept_loop(self) -> None: - try: - while self.is_running(): - with self._lock: - server_socket = self._server_socket - if server_socket is None: - break - try: - connection, address = server_socket.accept() - except socket.timeout: - continue - except OSError: - if self.is_running(): - self._log("Server socket closed unexpectedly.", level="warning") - break - - if not self._allow_connect_attempt(address[0]): - with self._lock: - self._stats["pendingConnectionsRejected"] += 1 - self._send_session_ended_raw(connection, code=SESSION_ENDED_RATE_LIMITED, reason="Too many connection attempts.") - self._close_raw_socket(connection) - continue - - thread = threading.Thread(target=self._handle_client, args=(connection, address), daemon=True) - with self._lock: - self._client_threads.add(thread) - thread.start() - finally: - with self._lock: - server_socket = self._server_socket - self._running = False - self._server_socket = None - if server_socket is not None: - try: - server_socket.close() - except OSError: - pass - - def _assign_client(self, connection: socket.socket, address: tuple[str, int]) -> ClientSession | None: - with self._lock: - max_pending = max(16, self.max_players * 2) - pending_count = sum(1 for client in self._clients.values() if not client.gameplay_active) - if pending_count >= max_pending: - return None - client = ClientSession( - connection=connection, - address=address, - player_id=self._next_player_id, - connected_at=time.time(), - ) - self._next_player_id += 1 - self._clients[connection] = client - return client - - def _activate_client(self, client: ClientSession, protocol_version: int) -> bool: - with self._lock: - if client.gameplay_active: - return True - active_count = len(self._active_clients_locked()) - if active_count >= self.max_players: - return False - if not client.mark_gameplay_active(protocol_version): - return True - self._stats["clientsConnected"] += 1 - if protocol_version >= PROTOCOL_VERSION: - self._stats["protocolV2Connections"] += 1 - else: - self._stats["legacyConnections"] += 1 - if self._world_state_host_player_id is None: - self._world_state_host_player_id = client.player_id - became_host = True - else: - became_host = False - - self._log(f"Client connected: {client.label} (player {client.player_id}, protocol {protocol_version})") - self._send_packet( - client, - { - "type": "sessionReady", - "playerId": client.player_id, - "protocolVersion": protocol_version, - "serverProtocolVersion": PROTOCOL_VERSION, - "worldStateHostPlayerId": self._world_state_host_player_id, - "serverTime": time.time(), - }, - ) - self._send_existing_transforms_to_client(client) - self._send_existing_player_states_to_client(client) - self._send_existing_npc_state_to_client(client) - if became_host: - self._broadcast_world_state_host_assignment(client.player_id) - return True - - def _handle_client(self, connection: socket.socket, address: tuple[str, int]) -> None: - peer_ip = address[0] - ban_entry = self._ban_store.get_ban(peer_ip) - if ban_entry is not None: - with self._lock: - self._stats["bannedConnectionsRejected"] += 1 - self._send_session_ended_raw(connection, code=SESSION_ENDED_BANNED, reason=ban_entry.reason) - self._close_raw_socket(connection) - return - - client = self._assign_client(connection, address) - if client is None: - self._send_session_ended_raw(connection, code=SESSION_ENDED_RATE_LIMITED, reason="Too many pending connections.") - self._close_raw_socket(connection) - return - - connection.settimeout(1.0) - connected_mono = time.monotonic() - self._init_rate_state(client, connected_mono) - self._log(f"TCP accept: {client.label} (provisional player {client.player_id})", level="debug") - - try: - with connection: - self._send_packet( - client, - { - "type": "welcome", - "playerId": client.player_id, - "serverTime": time.time(), - "serverName": self.server_name, - "serverDescription": self.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", - ], - }, - ) - buffer = b"" - while self.is_running(): - now_mono = time.monotonic() - if not client.gameplay_active and now_mono - connected_mono > CLIENT_HANDSHAKE_TIMEOUT_SECONDS: - self._log(f"Handshake timeout: {client.label}", level="warning") - break - if client.gameplay_active and client.last_packet_at is not None and time.time() - client.last_packet_at > CLIENT_IDLE_TIMEOUT_SECONDS: - self._log(f"Idle timeout: player {client.player_id}", level="warning") - break - try: - chunk = connection.recv(4096) - except socket.timeout: - continue - if not chunk: - break - buffer += chunk - if len(buffer) > MAX_PACKET_CHARS and b"\n" not in buffer: - self._end_client_session(client, code=SESSION_ENDED_PACKET_TOO_LARGE, reason="Packet exceeded maximum line size.") - break - while b"\n" in buffer: - line_bytes, buffer = buffer.split(b"\n", 1) - if len(line_bytes) > MAX_PACKET_CHARS: - self._end_client_session(client, code=SESSION_ENDED_PACKET_TOO_LARGE, reason="Packet exceeded maximum line size.") - return - try: - line = line_bytes.decode("utf-8", errors="strict").strip() - except UnicodeDecodeError: - self._reject_packet(client, "Packet is not valid UTF-8") - continue - if not self._handle_line(client, line): - if client.connection.fileno() < 0: - return - except (ConnectionResetError, BrokenPipeError): - pass - except OSError as error: - if self.is_running(): - self._log(f"Client connection error: {client.label}: {error}", level="debug") - finally: - self._disconnect_client(client) - with self._lock: - self._client_threads.discard(threading.current_thread()) - - def _init_rate_state(self, client: ClientSession, now: float) -> None: - client._rate_window_start = now - client._rate_window_count = 0 - client._rate_violations = 0 - client._rate_window_blocked = False - client._last_combat_sequence = 0 - - def _allow_packet(self, client: ClientSession) -> bool: - now = time.monotonic() - start = getattr(client, "_rate_window_start", now) - if now - start >= 1.0: - previous_count = getattr(client, "_rate_window_count", 0) - violations = getattr(client, "_rate_violations", 0) - if previous_count <= MAX_PACKETS_PER_SECOND: - violations = max(0, violations - 1) - client._rate_violations = violations - client._rate_window_start = now - client._rate_window_count = 0 - client._rate_window_blocked = False - client._rate_window_count += 1 - if client._rate_window_count <= MAX_PACKETS_PER_SECOND: - return True - with self._lock: - self._stats["rateLimitedPackets"] += 1 - if not client._rate_window_blocked: - client._rate_window_blocked = True - client._rate_violations += 1 - self._log(f"Packet-rate limit exceeded by player {client.player_id} ({client._rate_violations}/3 windows)", level="warning") - if client._rate_violations >= 3: - self._end_client_session(client, code=SESSION_ENDED_RATE_LIMITED, reason="Sustained packet-rate limit exceeded.") - return False - - def _handle_line(self, client: ClientSession, line: str) -> bool: - if not line: - return True - if not self._allow_packet(client): - return False - - client.record_received(time.time()) - with self._lock: - self._stats["packetsReceived"] += 1 - - try: - packet = json.loads(line, parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value))) - except (json.JSONDecodeError, ValueError) as error: - self._reject_packet(client, f"Invalid JSON: {error}") - return False - if not isinstance(packet, dict): - self._reject_packet(client, "Packet must be a JSON object") - return False - - packet_type = packet.get("type") - if packet_type == "hello": - return self._handle_hello(client, packet) - if packet_type == "keepAlive": - return True - - if not client.gameplay_active: - if packet_type not in {"transform", "worldState", "npcState", "combatHit"}: - self._reject_packet(client, "Gameplay packet received before session activation") - return False - if not self._activate_client(client, LEGACY_PROTOCOL_VERSION): - self._end_client_session(client, code=SESSION_ENDED_FULL, reason="Server is full.") - return False - self._log(f"Legacy client player {client.player_id} activated without hello; client upgrade recommended.", level="warning") - - if packet_type == "transform": - normalized = self._normalize_transform_packet(packet) - if normalized is None: - self._reject_packet(client, "Malformed transform") - return False - if client.protocol_version >= PROTOCOL_VERSION: - for reliable_field in ("equippedItems", "appearance", "actionEvents", "characterName"): - normalized.pop(reliable_field, None) - accepted_monotonic = time.monotonic() - movement_valid, movement_reason = self._validate_transform_movement(client, normalized, accepted_monotonic) - if not movement_valid: - with self._lock: - self._stats["movementPacketsRejected"] += 1 - self._reject_packet(client, movement_reason) - self._send_position_correction(client, movement_reason) - return False - previous_scope = scope_from_transform(client.last_transform) - normalized["playerId"] = client.player_id - normalized["serverTime"] = time.time() - client.record_transform(normalized, accepted_monotonic) - with self._lock: - self._stats["transformPacketsReceived"] += 1 - self._broadcast_transform(client, normalized) - self._reconcile_npc_authority() - current_scope = scope_from_transform(normalized) - if current_scope is not None and current_scope != previous_scope: - self._send_npc_authority_for_client(client, current_scope) - return True - - if packet_type == "playerState": - if client.protocol_version < PROTOCOL_VERSION: - self._reject_packet(client, "playerState requires Protocol V2", warning=False) - return False - normalized = normalize_player_state_packet(packet, _normalize_action_events) - if normalized is None: - self._reject_packet(client, "Malformed playerState") - return False - normalized["playerId"] = client.player_id - normalized["serverTime"] = time.time() - durable_fields = ("equippedItems", "appearance", "characterName") - with self._lock: - if any(field in normalized for field in durable_fields): - cached = dict(self._last_player_state_by_player_id.get(client.player_id, {})) - cached.update({"type": "playerState", "playerId": client.player_id, "serverTime": normalized["serverTime"]}) - for field in durable_fields: - if field in normalized: - cached[field] = normalized[field] - cached.pop("actionEvents", None) - self._last_player_state_by_player_id[client.player_id] = cached - self._stats["playerStatePacketsReceived"] += 1 - self._broadcast_player_state(client, normalized) - return True - - if packet_type == "worldState": - if not self._is_world_host(client): - self._reject_packet(client, "worldState from non-authority client", warning=False) - return False - normalized = self._normalize_world_state_packet(packet) - if normalized is None: - self._reject_packet(client, "Malformed worldState") - return False - normalized["playerId"] = client.player_id - normalized["serverTime"] = time.time() - with self._lock: - self._stats["worldStatePacketsReceived"] += 1 - self._broadcast_world_state(client, normalized) - return True - - if packet_type == "npcState": - normalized = self._normalize_npc_state_packet(packet) - if normalized is None: - self._reject_packet(client, "Malformed npcState") - return False - - if client.protocol_version >= PROTOCOL_VERSION: - scope = self._npc_scope_from_packet(normalized) - epoch = normalized.get("authorityEpoch") - if scope is None or not _is_int(epoch, 1): - with self._lock: - self._stats["npcAuthorityRejects"] += 1 - self._reject_packet(client, "Protocol V2 npcState missing valid authority scope/epoch") - return False - if not self._npc_authority.authorize(client.player_id, scope, epoch): - with self._lock: - self._stats["npcAuthorityRejects"] += 1 - self._reject_packet(client, "Stale or unauthorized npcState authority epoch") - return False - if any( - npc.get("cellId") != scope.cell_id or npc.get("worldspaceId", "") != scope.worldspace_id - for npc in normalized.get("npcs", []) - ): - with self._lock: - self._stats["npcAuthorityRejects"] += 1 - self._reject_packet(client, "npcState contains NPCs outside declared authority scope") - return False - normalized["authorityCellId"] = scope.cell_id - normalized["authorityWorldspaceId"] = scope.worldspace_id - with self._lock: - self._last_npc_state_by_scope[scope] = dict(normalized) - else: - if not self._is_world_host(client): - self._reject_packet(client, "Legacy npcState from non-authority client", warning=False) - return False - with self._lock: - self._last_npc_state = dict(normalized) - - normalized["playerId"] = client.player_id - normalized["serverTime"] = time.time() - normalized["fullReplace"] = True - with self._lock: - self._stats["npcStatePacketsReceived"] += 1 - self._broadcast_npc_state(client, normalized) - return True - - if packet_type == "combatHit": - normalized = self._normalize_combat_hit_packet(packet) - if normalized is None: - self._reject_packet(client, "Malformed combatHit") - return False - sequence = normalized["sequence"] - if sequence <= getattr(client, "_last_combat_sequence", 0): - self._reject_packet(client, "Duplicate or out-of-order combat sequence", warning=False) - return False - client._last_combat_sequence = sequence - normalized["playerId"] = client.player_id - normalized["serverTime"] = time.time() - with self._lock: - self._stats["combatHitsReceived"] += 1 - self._route_combat_hit(client, normalized) - return True - - self._reject_packet(client, f"Unknown packet type: {_bounded_repr(packet_type)}") - return False - - def _handle_hello(self, client: ClientSession, packet: dict[str, Any]) -> bool: - version = packet.get("protocolVersion") - if not _is_int(version, 1, 0xFFFF) or version != PROTOCOL_VERSION: - self._end_client_session(client, code=SESSION_ENDED_PROTOCOL, reason=f"Server requires protocol {PROTOCOL_VERSION}.") - return False - if not self._activate_client(client, version): - self._end_client_session(client, code=SESSION_ENDED_FULL, reason="Server is full.") - return False - return True - - def _normalize_transform_packet(self, packet: dict[str, Any]) -> dict[str, Any] | None: - for field_name in ("x", "y", "z", "angleZ"): - if not _is_finite_number(packet.get(field_name), -MAX_ABS_COORDINATE, MAX_ABS_COORDINATE): - return None - if not _is_hex_form_id(packet.get("cellId"), allow_zero=False): - return None - worldspace_id = packet.get("worldspaceId", "") - if not _is_hex_form_id(worldspace_id, allow_empty=True, allow_zero=True): - return None - - movement_type = packet.get("movementType", "normal") - if not isinstance(movement_type, str) or movement_type not in ALLOWED_MOVEMENT_TYPES: - return None - - normalized = dict(packet) - normalized["x"] = float(packet["x"]) - normalized["y"] = float(packet["y"]) - normalized["z"] = float(packet["z"]) - normalized["angleZ"] = float(packet["angleZ"]) - normalized["cellId"] = _normalize_hex_form_id(packet["cellId"]) - normalized["worldspaceId"] = _normalize_hex_form_id(worldspace_id) if worldspace_id else "" - normalized["movementType"] = movement_type - for field_name in ("movementSpeed", "animationGraphSpeed"): - if field_name in packet: - value = packet[field_name] - if not _is_finite_number(value, -1.0 if field_name == "animationGraphSpeed" else 0.0, MAX_MOVEMENT_SPEED): - normalized.pop(field_name, None) - else: - normalized[field_name] = float(value) - for field_name, minimum, maximum in ( - ("animationDirection", -360.0, 360.0), - ("aimPitch", -180.0, 180.0), - ("turnDelta", -10000.0, 10000.0), - ): - if field_name in packet: - if not _is_finite_number(packet[field_name], minimum, maximum): - normalized.pop(field_name, None) - else: - normalized[field_name] = float(packet[field_name]) - for field_name in ("isMoving", "isSprinting", "isSneaking", "isJumping", "isCrouching", "weaponDrawn"): - if field_name in packet and not isinstance(packet[field_name], bool): - normalized.pop(field_name, None) - for field_name in ("actorStateFlags1", "actorStateFlags2"): - if field_name in packet and not _is_int(packet[field_name]): - normalized.pop(field_name, None) - if "actionEvents" in packet: - normalized["actionEvents"] = _normalize_action_events(packet["actionEvents"]) - return normalized - - def _validate_transform_movement(self, client: ClientSession, packet: dict[str, Any], now_monotonic: float) -> tuple[bool, str]: - previous, previous_monotonic = client.get_last_transform_anchor() - if previous is None or previous_monotonic is None: - return True, "first transform" - movement_type = packet.get("movementType", "normal") - if movement_type in MOVEMENT_TRANSITION_TYPES: - return True, f"explicit {movement_type} transition" - previous_cell = previous.get("cellId", "") - current_cell = packet.get("cellId", "") - previous_world = previous.get("worldspaceId", "") - current_world = packet.get("worldspaceId", "") - if previous_cell != current_cell or previous_world != current_world: - return False, "scope changed without an explicit movement transition" - elapsed = now_monotonic - previous_monotonic - if not math.isfinite(elapsed) or elapsed < 0.0: - elapsed = 0.0 - elapsed = min(elapsed, MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS) - dx = packet["x"] - previous["x"] - dy = packet["y"] - previous["y"] - dz = packet["z"] - previous["z"] - distance = math.sqrt((dx * dx) + (dy * dy) + (dz * dz)) - allowed_distance = MOVEMENT_GRACE_DISTANCE + (MAX_NORMAL_MOVEMENT_SPEED * elapsed) - if not math.isfinite(distance) or distance > allowed_distance: - return False, f"normal movement exceeded server envelope: distance={distance:.1f}, allowed={allowed_distance:.1f}, elapsed={elapsed:.3f}s" - return True, "normal movement accepted" - - def _send_position_correction(self, client: ClientSession, reason: str) -> None: - previous, _previous_monotonic = client.get_last_transform_anchor() - if previous is None: - return - packet: dict[str, Any] = { - "type": "positionCorrection", - "reason": reason[:160], - "x": previous["x"], - "y": previous["y"], - "z": previous["z"], - "angleZ": previous["angleZ"], - "cellId": previous["cellId"], - "worldspaceId": previous.get("worldspaceId", ""), - "serverTime": time.time(), - } - try: - self._send_packet(client, packet) - except (OSError, ValueError): - self._disconnect_client(client) - return - with self._lock: - self._stats["movementCorrectionsSent"] += 1 - - def _normalize_world_state_packet(self, packet: dict[str, Any]) -> dict[str, Any] | None: - normalized = dict(packet) - if "gameHour" in packet: - if not _is_finite_number(packet["gameHour"], 0.0, 24.0): - return None - normalized["gameHour"] = float(packet["gameHour"]) - if "gameDaysPassed" in packet: - if not _is_finite_number(packet["gameDaysPassed"], 0.0, 10_000_000.0): - return None - normalized["gameDaysPassed"] = float(packet["gameDaysPassed"]) - weather = packet.get("weatherFormId") - if weather is not None: - if not _is_hex_form_id(weather, allow_empty=True, allow_zero=True): - return None - normalized["weatherFormId"] = _normalize_hex_form_id(weather) if weather else "" - return normalized - - def _normalize_npc_state_packet(self, packet: dict[str, Any]) -> dict[str, Any] | None: - npcs = packet.get("npcs") - if not isinstance(npcs, list) or len(npcs) > MAX_NPCS_PER_PACKET: - return None - clean_npcs: list[dict[str, Any]] = [] - for npc in npcs: - if not isinstance(npc, dict): - return None - if not _is_hex_form_id(npc.get("sourceFormId"), allow_zero=False): - return None - if not _is_hex_form_id(npc.get("cellId"), allow_zero=False): - return None - worldspace = npc.get("worldspaceId", "") - if not _is_hex_form_id(worldspace, allow_empty=True, allow_zero=True): - return None - for field_name in ("x", "y", "z", "angleZ"): - if not _is_finite_number(npc.get(field_name), -MAX_ABS_COORDINATE, MAX_ABS_COORDINATE): - return None - clean = dict(npc) - clean["sourceFormId"] = _normalize_hex_form_id(npc["sourceFormId"]) - clean["cellId"] = _normalize_hex_form_id(npc["cellId"]) - clean["worldspaceId"] = _normalize_hex_form_id(worldspace) if worldspace else "" - for field_name in ("x", "y", "z", "angleZ"): - clean[field_name] = float(npc[field_name]) - clean_npcs.append(clean) - normalized = dict(packet) - normalized["npcs"] = clean_npcs - if "authorityEpoch" in packet: - if not _is_int(packet["authorityEpoch"], 1): - return None - normalized["authorityEpoch"] = int(packet["authorityEpoch"]) - if "authorityCellId" in packet: - if not _is_hex_form_id(packet["authorityCellId"], allow_zero=False): - return None - normalized["authorityCellId"] = _normalize_hex_form_id(packet["authorityCellId"]) - if "authorityWorldspaceId" in packet: - world = packet["authorityWorldspaceId"] - if not _is_hex_form_id(world, allow_empty=True, allow_zero=True): - return None - normalized["authorityWorldspaceId"] = _normalize_hex_form_id(world) if world else "" - return normalized - - def _npc_scope_from_packet(self, packet: dict[str, Any]) -> ScopeKey | None: - cell = packet.get("authorityCellId") - world = packet.get("authorityWorldspaceId", "") - if not _is_hex_form_id(cell, allow_zero=False): - return None - if not _is_hex_form_id(world, allow_empty=True, allow_zero=True): - return None - return ScopeKey(_normalize_hex_form_id(cell), _normalize_hex_form_id(world) if world else "") - - def _normalize_combat_hit_packet(self, packet: dict[str, Any]) -> dict[str, Any] | None: - target = packet.get("targetPlayerId") - sequence = packet.get("sequence") - damage = packet.get("damage") - if isinstance(target, float) and target.is_integer(): - target = int(target) - if isinstance(sequence, float) and sequence.is_integer(): - sequence = int(sequence) - if not _is_int(target, 1) or not _is_int(sequence, 1): - return None - if not _is_finite_number(damage, 0.000001, 10000.0): - return None - weapon = packet.get("weaponFormId") - if weapon is not None and not _is_hex_form_id(weapon, allow_zero=True): - return None - normalized = dict(packet) - normalized["targetPlayerId"] = target - normalized["sequence"] = sequence - normalized["damage"] = float(damage) - if isinstance(weapon, str): - normalized["weaponFormId"] = _normalize_hex_form_id(weapon) - return normalized - - def _is_world_host(self, client: ClientSession) -> bool: - with self._lock: - return self._world_state_host_player_id == client.player_id - - def _authority_packet(self, assignment_player_id: int, epoch: int, scope: ScopeKey) -> dict[str, Any]: - return { - "type": "npcAuthority", - "authorityPlayerId": assignment_player_id, - "authorityEpoch": epoch, - "authorityCellId": scope.cell_id, - "authorityWorldspaceId": scope.worldspace_id, - "serverTime": time.time(), - } - - def _reconcile_npc_authority(self) -> None: - with self._lock: - players = [] - for client in self._active_clients_locked(): - if client.protocol_version < PROTOCOL_VERSION: - continue - scope = scope_from_transform(client.last_transform) - if scope is not None: - players.append((client.player_id, scope)) - changes = self._npc_authority.reconcile(players) - for change in changes: - self._last_npc_state_by_scope.pop(change.scope, None) - for change in changes: - packet = self._authority_packet(change.player_id, change.epoch, change.scope) - successful, failed = self._broadcast_to_active(packet, v2_only=True) - with self._lock: - self._stats["npcAuthorityChanges"] += 1 - for client in failed: - self._disconnect_client(client) - self._log( - f"NPC authority scope {change.scope.cell_id}/{change.scope.worldspace_id or ''}: " - f"{change.previous_player_id} -> {change.player_id}, epoch {change.epoch} " - f"(notified {successful} client(s))." - ) - - def _send_npc_authority_for_client(self, client: ClientSession, scope: ScopeKey) -> None: - if client.protocol_version < PROTOCOL_VERSION: - return - assignment = self._npc_authority.get(scope) - if assignment is None: - return - try: - self._send_packet(client, self._authority_packet(assignment.player_id, assignment.epoch, scope)) - except (OSError, ValueError): - self._disconnect_client(client) - - def _send_packet(self, client: ClientSession, packet: dict[str, Any], *, broadcast: bool = False) -> None: - encoded = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode("utf-8") + b"\n" - if len(encoded) > MAX_PACKET_CHARS: - raise ValueError("Outbound packet exceeds maximum line size") - client.send_bytes(encoded) - client.record_sent(broadcast=broadcast) - with self._lock: - self._stats["packetsSent"] += 1 - if broadcast: - self._stats["packetsBroadcast"] += 1 - - def _send_existing_transforms_to_client(self, new_client: ClientSession) -> None: - with self._lock: - peers = [client for client in self._active_clients_locked() if client.connection != new_client.connection and client.last_transform is not None] - successful = 0 - for peer in peers: - if not states_share_interest(peer.last_transform, new_client.last_transform): - continue - packet = dict(peer.last_transform or {}) - packet["serverTime"] = time.time() - try: - self._send_packet(new_client, packet, broadcast=True) - successful += 1 - except (OSError, ValueError): - self._disconnect_client(new_client) - break - with self._lock: - self._stats["transformPacketsBroadcast"] += successful - - def _send_existing_player_states_to_client(self, new_client: ClientSession) -> None: - if new_client.protocol_version < PROTOCOL_VERSION: - return - with self._lock: - snapshots = [ - dict(packet) - for player_id, packet in self._last_player_state_by_player_id.items() - if player_id != new_client.player_id - ] - successful = 0 - for packet in snapshots: - packet["serverTime"] = time.time() - try: - self._send_packet(new_client, packet, broadcast=True) - successful += 1 - except (OSError, ValueError): - self._disconnect_client(new_client) - break - with self._lock: - self._stats["playerStatePacketsBroadcast"] += successful - - def _broadcast_transform(self, sender: ClientSession, packet: dict[str, Any]) -> None: - with self._lock: - recipients = [client for client in self._active_clients_locked() if client.connection != sender.connection] - successful = 0 - filtered = 0 - failed: list[ClientSession] = [] - for recipient in recipients: - if not states_share_interest(packet, recipient.last_transform): - filtered += 1 - continue - try: - self._send_packet(recipient, packet, broadcast=True) - successful += 1 - except (OSError, ValueError): - failed.append(recipient) - with self._lock: - self._stats["transformPacketsBroadcast"] += successful - self._stats["transformPacketsInterestFiltered"] += filtered - for recipient in failed: - self._disconnect_client(recipient) - - def _broadcast_player_state(self, sender: ClientSession, packet: dict[str, Any]) -> None: - with self._lock: - recipients = [ - client - for client in self._active_clients_locked() - if client.connection != sender.connection and client.protocol_version >= PROTOCOL_VERSION - ] - successful = 0 - failed: list[ClientSession] = [] - durable_fields = ("equippedItems", "appearance", "characterName") - sender_scope_known = _scope_from_state(sender.last_transform) is not None - for recipient in recipients: - relay_packet = dict(packet) - if "actionEvents" in relay_packet: - recipient_scope_known = _scope_from_state(recipient.last_transform) is not None - if not sender_scope_known or not recipient_scope_known or not states_share_interest(sender.last_transform, recipient.last_transform): - relay_packet.pop("actionEvents", None) - if "actionEvents" not in relay_packet and not any(field in relay_packet for field in durable_fields): - continue - try: - self._send_packet(recipient, relay_packet, broadcast=True) - successful += 1 - except (OSError, ValueError): - failed.append(recipient) - with self._lock: - self._stats["playerStatePacketsBroadcast"] += successful - for client in failed: - self._disconnect_client(client) - - def _broadcast_world_state(self, sender: ClientSession, packet: dict[str, Any]) -> None: - successful, failed = self._broadcast_to_active(packet, exclude=sender) - with self._lock: - self._stats["worldStatePacketsBroadcast"] += successful - for client in failed: - self._disconnect_client(client) - - def _npc_packet_for_recipient(self, packet: dict[str, Any], recipient: ClientSession) -> dict[str, Any]: - if recipient.last_transform is None: - return dict(packet) - clean = dict(packet) - clean["npcs"] = [npc for npc in packet.get("npcs", []) if states_share_interest(npc, recipient.last_transform)] - return clean - - def _broadcast_npc_state(self, sender: ClientSession, packet: dict[str, Any]) -> None: - with self._lock: - recipients = [client for client in self._active_clients_locked() if client.connection != sender.connection] - successful = 0 - failed: list[ClientSession] = [] - for recipient in recipients: - scoped_packet = self._npc_packet_for_recipient(packet, recipient) - try: - self._send_packet(recipient, scoped_packet, broadcast=True) - successful += 1 - except (OSError, ValueError): - failed.append(recipient) - with self._lock: - self._stats["npcStatePacketsBroadcast"] += successful - for client in failed: - self._disconnect_client(client) - - def _send_existing_npc_state_to_client(self, client: ClientSession) -> None: - if client.protocol_version >= PROTOCOL_VERSION: - scope = scope_from_transform(client.last_transform) - with self._lock: - snapshot = dict(self._last_npc_state_by_scope[scope]) if scope in self._last_npc_state_by_scope else None - else: - with self._lock: - snapshot = dict(self._last_npc_state) if self._last_npc_state is not None else None - if snapshot is None or snapshot.get("playerId") == client.player_id: - return - scoped = self._npc_packet_for_recipient(snapshot, client) - scoped["serverTime"] = time.time() - try: - self._send_packet(client, scoped, broadcast=True) - with self._lock: - self._stats["npcStatePacketsBroadcast"] += 1 - except (OSError, ValueError): - self._disconnect_client(client) - - def _route_combat_hit(self, sender: ClientSession, packet: dict[str, Any]) -> None: - target_player_id = packet["targetPlayerId"] - if target_player_id == sender.player_id: - self._reject_packet(sender, "Self-targeted combatHit", warning=False) - return - recipient = self._find_client_by_player_id(target_player_id) - if recipient is None: - self._reject_packet(sender, f"Combat target {target_player_id} is not connected", warning=False) - return - if not states_share_interest(sender.last_transform, recipient.last_transform): - self._reject_packet(sender, f"Combat target {target_player_id} is outside interest scope", warning=True) - return - try: - self._send_packet(recipient, packet) - with self._lock: - self._stats["combatHitsRouted"] += 1 - except (OSError, ValueError): - self._disconnect_client(recipient) - - def _broadcast_server_world_state(self) -> None: - with self._lock: - recipients = self._active_clients_locked() - snapshot = dict(self._server_world_state) - packets: list[dict[str, Any]] = [] - if snapshot.get("timeHHmm"): - packets.append({"type": "serverWorldState", "timeHHmm": snapshot["timeHHmm"], "serverTime": time.time()}) - if snapshot.get("weatherConsoleArg"): - weather_packet: dict[str, Any] = {"type": "serverWorldState", "weatherConsoleArg": snapshot["weatherConsoleArg"], "serverTime": time.time()} - if snapshot.get("weatherFormId"): - weather_packet["weatherFormId"] = snapshot["weatherFormId"] - packets.append(weather_packet) - successful = 0 - failed: list[ClientSession] = [] - for packet in packets: - for recipient in recipients: - try: - self._send_packet(recipient, packet, broadcast=True) - successful += 1 - except (OSError, ValueError): - failed.append(recipient) - with self._lock: - self._stats["serverWorldStatePacketsBroadcast"] += successful - for client in {failed_client.connection: failed_client for failed_client in failed}.values(): - self._disconnect_client(client) - - def _broadcast_to_active( - self, - packet: dict[str, Any], - *, - exclude: ClientSession | None = None, - v2_only: bool = False, - ) -> tuple[int, list[ClientSession]]: - with self._lock: - recipients = [ - client - for client in self._active_clients_locked() - if (exclude is None or client.connection != exclude.connection) - and (not v2_only or client.protocol_version >= PROTOCOL_VERSION) - ] - successful = 0 - failed: list[ClientSession] = [] - for recipient in recipients: - try: - self._send_packet(recipient, packet, broadcast=True) - successful += 1 - except (OSError, ValueError): - failed.append(recipient) - return successful, failed - - def _broadcast_world_state_host_assignment(self, player_id: int) -> None: - packet = {"type": "worldStateHost", "worldStateHostPlayerId": player_id, "serverTime": time.time()} - successful, failed = self._broadcast_to_active(packet) - with self._lock: - self._stats["worldStateHostPacketsBroadcast"] += successful - for client in failed: - self._disconnect_client(client) - - def _reassign_world_state_host(self) -> None: - with self._lock: - active = self._active_clients_locked() - if not active: - self._world_state_host_player_id = None - self._last_npc_state = None - return - new_host = min(client.player_id for client in active) - self._world_state_host_player_id = new_host - self._last_npc_state = None - self._broadcast_world_state_host_assignment(new_host) - self._log(f"Reassigned world-state host to player {new_host}.") - - def _broadcast_disconnect(self, client: ClientSession) -> None: - packet = {"type": "disconnect", "playerId": client.player_id, "serverTime": time.time()} - successful, failed = self._broadcast_to_active(packet) - with self._lock: - self._stats["disconnectPacketsBroadcast"] += successful - for recipient in failed: - self._disconnect_client(recipient) - - def _remove_client(self, client: ClientSession) -> bool: - with self._lock: - removed = self._clients.pop(client.connection, None) is not None - if removed: - self._last_player_state_by_player_id.pop(client.player_id, None) - if removed and client.gameplay_active: - self._stats["clientsDisconnected"] += 1 - return removed - - def _disconnect_client(self, client: ClientSession) -> None: - with self._lock: - was_active = client.gameplay_active - was_host = was_active and client.player_id == self._world_state_host_player_id - if not self._remove_client(client): - return - self._close_client_socket(client) - if not was_active: - self._log(f"Closed pending/probe connection: {client.label}", level="debug") - return - self._log(f"Client disconnected: {client.label} (player {client.player_id})") - self._broadcast_disconnect(client) - self._reconcile_npc_authority() - if was_host: - self._reassign_world_state_host() - - def _find_client_by_player_id(self, player_id: int) -> ClientSession | None: - with self._lock: - return next((client for client in self._active_clients_locked() if client.player_id == player_id), None) - - def _clients_for_ip(self, ip: str) -> list[ClientSession]: - normalized = str(ip).strip() - with self._lock: - return [client for client in self._clients.values() if client.address[0] == normalized] - - def _build_session_ended_packet(self, code: str, reason: str = "") -> dict[str, Any]: - return {"type": "sessionEnded", "code": code, "reason": str(reason or ""), "serverTime": time.time()} - - def _send_session_ended_raw(self, connection: socket.socket, *, code: str, reason: str = "") -> None: - packet = self._build_session_ended_packet(code, reason) - encoded = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode("utf-8") + b"\n" - try: - connection.settimeout(1.0) - connection.sendall(encoded) - with self._lock: - self._stats["sessionEndedPacketsSent"] += 1 - self._stats["packetsSent"] += 1 - except OSError: - pass - - def _end_client_session(self, client: ClientSession, *, code: str, reason: str = "") -> None: - try: - self._send_packet(client, self._build_session_ended_packet(code, reason)) - with self._lock: - self._stats["sessionEndedPacketsSent"] += 1 - except (OSError, ValueError): - pass - self._disconnect_client(client) - - def _end_sessions_for_ip(self, ip: str, *, code: str, reason: str = "") -> int: - clients = self._clients_for_ip(ip) - for client in clients: - self._end_client_session(client, code=code, reason=reason) - return len(clients) - - def _close_raw_socket(self, connection: socket.socket) -> None: - try: - connection.close() - except OSError: - pass - - def _close_client_socket(self, client: ClientSession) -> None: - self._close_raw_socket(client.connection) - - def _reject_packet(self, client: ClientSession, reason: str, *, warning: bool = True) -> None: - with self._lock: - self._stats["packetsRejected"] += 1 - self._log(f"Rejected packet from {client.label} (player {client.player_id}): {reason}", level="warning" if warning else "debug") - - def _should_log(self, level: str) -> bool: - configured = _LOG_LEVELS.get(self.log_verbosity, _LOG_LEVELS[DEFAULT_LOG_VERBOSITY]) - message_level = _LOG_LEVELS.get(level, _LOG_LEVELS["info"]) - return message_level >= configured - - def _log(self, message: str, *, level: str = "info") -> None: - if not self._should_log(level): - return - with self._log_lock: - listeners = list(self._log_listeners) - if not listeners: - return - for listener in listeners: - try: - try: - listener(message, level=level) - except TypeError: - listener(message) - except Exception: - pass diff --git a/server/server_service.py b/server/server_service.py deleted file mode 100644 index 7924c3c..0000000 --- a/server/server_service.py +++ /dev/null @@ -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) diff --git a/server/snapshot_sequence.py b/server/snapshot_sequence.py deleted file mode 100644 index 11f909f..0000000 --- a/server/snapshot_sequence.py +++ /dev/null @@ -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 diff --git a/server/src/README.md b/server/src/README.md deleted file mode 100644 index 267370f..0000000 --- a/server/src/README.md +++ /dev/null @@ -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 diff --git a/server/start.bat b/server/start.bat index 69fe1bd..d292126 100644 --- a/server/start.bat +++ b/server/start.bat @@ -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% diff --git a/server/start.sh b/server/start.sh old mode 100644 new mode 100755 index 18180da..24c4405 --- a/server/start.sh +++ b/server/start.sh @@ -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[@]}" diff --git a/server/tcp_transport.py b/server/tcp_transport.py deleted file mode 100644 index bec3ab5..0000000 --- a/server/tcp_transport.py +++ /dev/null @@ -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 diff --git a/server/test_combat_protocol.py b/server/test_combat_protocol.py deleted file mode 100644 index 423745a..0000000 --- a/server/test_combat_protocol.py +++ /dev/null @@ -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() diff --git a/server/test_npc_protocol.py b/server/test_npc_protocol.py deleted file mode 100644 index 2042273..0000000 --- a/server/test_npc_protocol.py +++ /dev/null @@ -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() diff --git a/server/tests/Program.cs b/server/tests/Program.cs index 38072c5..7641655 100644 --- a/server/tests/Program.cs +++ b/server/tests/Program.cs @@ -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(() => PacketCodec.Encode(new JsonObject { ["type"] = "x", ["blob"] = new string('a', ProtocolConstants.MaxMessageBytes + 1) })); + Equal("Nomad", JsonHelpers.String(PacketCodec.Decode(encoded.Payload)["characterName"])); Throws(() => PacketCodec.Decode("[]"u8)); + Throws(() => 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(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(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(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 expected, T actual) where T : notnull { if (!EqualityComparer.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 expected, T actual) { if (!EqualityComparer.Default.Equals(expected, actual)) throw new Exception($"expected {expected}, got {actual}"); } private static void Throws(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 _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 SentPackets { get { lock (_gate) return _sent.Select(JsonHelpers.CloneObject).ToArray(); } } - public ValueTask 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(); } diff --git a/server/tests/__init__.py b/server/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/server/tests/conftest.py b/server/tests/conftest.py deleted file mode 100644 index e75e5f6..0000000 --- a/server/tests/conftest.py +++ /dev/null @@ -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)) diff --git a/server/tests/test_config_portability.py b/server/tests/test_config_portability.py deleted file mode 100644 index fea3d55..0000000 --- a/server/tests/test_config_portability.py +++ /dev/null @@ -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)) diff --git a/server/tests/test_dedicated_server_config.py b/server/tests/test_dedicated_server_config.py deleted file mode 100644 index 5ba55a4..0000000 --- a/server/tests/test_dedicated_server_config.py +++ /dev/null @@ -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") diff --git a/server/tests/test_gns_gameplay_adapter.py b/server/tests/test_gns_gameplay_adapter.py deleted file mode 100644 index 6f29b25..0000000 --- a/server/tests/test_gns_gameplay_adapter.py +++ /dev/null @@ -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" diff --git a/server/tests/test_gns_snapshot_envelope.py b/server/tests/test_gns_snapshot_envelope.py deleted file mode 100644 index 3108e4a..0000000 --- a/server/tests/test_gns_snapshot_envelope.py +++ /dev/null @@ -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 diff --git a/server/tests/test_gns_transport.py b/server/tests/test_gns_transport.py deleted file mode 100644 index c5f2080..0000000 --- a/server/tests/test_gns_transport.py +++ /dev/null @@ -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() diff --git a/server/tests/test_networking_v2.py b/server/tests/test_networking_v2.py deleted file mode 100644 index 0f8e3f0..0000000 --- a/server/tests/test_networking_v2.py +++ /dev/null @@ -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() diff --git a/server/tests/test_networking_v2_load.py b/server/tests/test_networking_v2_load.py deleted file mode 100644 index 97d610e..0000000 --- a/server/tests/test_networking_v2_load.py +++ /dev/null @@ -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() diff --git a/server/tests/test_npc_authority.py b/server/tests/test_npc_authority.py deleted file mode 100644 index 72ba029..0000000 --- a/server/tests/test_npc_authority.py +++ /dev/null @@ -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 diff --git a/server/tests/test_packet_codec.py b/server/tests/test_packet_codec.py deleted file mode 100644 index f3202e1..0000000 --- a/server/tests/test_packet_codec.py +++ /dev/null @@ -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)) diff --git a/server/tests/test_player_state.py b/server/tests/test_player_state.py deleted file mode 100644 index c961a49..0000000 --- a/server/tests/test_player_state.py +++ /dev/null @@ -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 diff --git a/server/tests/test_player_state_integration.py b/server/tests/test_player_state_integration.py deleted file mode 100644 index f487869..0000000 --- a/server/tests/test_player_state_integration.py +++ /dev/null @@ -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() diff --git a/server/tests/test_protocol_v2_client.py b/server/tests/test_protocol_v2_client.py deleted file mode 100644 index fcf8a6b..0000000 --- a/server/tests/test_protocol_v2_client.py +++ /dev/null @@ -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) diff --git a/server/tests/test_runtime_portability.py b/server/tests/test_runtime_portability.py deleted file mode 100644 index 046cb2f..0000000 --- a/server/tests/test_runtime_portability.py +++ /dev/null @@ -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() diff --git a/server/tests/test_serve_cli_args.py b/server/tests/test_serve_cli_args.py deleted file mode 100644 index 0d06e07..0000000 --- a/server/tests/test_serve_cli_args.py +++ /dev/null @@ -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 diff --git a/server/tests/test_server_service_transport.py b/server/tests/test_server_service_transport.py deleted file mode 100644 index 82cf6cd..0000000 --- a/server/tests/test_server_service_transport.py +++ /dev/null @@ -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 diff --git a/server/tests/test_sigterm_integration.py b/server/tests/test_sigterm_integration.py deleted file mode 100644 index 74c2265..0000000 --- a/server/tests/test_sigterm_integration.py +++ /dev/null @@ -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) diff --git a/server/tests/test_snapshot_sequence.py b/server/tests/test_snapshot_sequence.py deleted file mode 100644 index ad555c1..0000000 --- a/server/tests/test_snapshot_sequence.py +++ /dev/null @@ -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) diff --git a/server/tests/test_tcp_transport.py b/server/tests/test_tcp_transport.py deleted file mode 100644 index ae82bc5..0000000 --- a/server/tests/test_tcp_transport.py +++ /dev/null @@ -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() diff --git a/server/tests/test_transport_policy.py b/server/tests/test_transport_policy.py deleted file mode 100644 index 3f86654..0000000 --- a/server/tests/test_transport_policy.py +++ /dev/null @@ -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") diff --git a/server/tests/test_transport_server.py b/server/tests/test_transport_server.py deleted file mode 100644 index 7695c09..0000000 --- a/server/tests/test_transport_server.py +++ /dev/null @@ -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 diff --git a/server/transport_policy.py b/server/transport_policy.py deleted file mode 100644 index 3385762..0000000 --- a/server/transport_policy.py +++ /dev/null @@ -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) diff --git a/server/transport_server.py b/server/transport_server.py deleted file mode 100644 index 5bb268d..0000000 --- a/server/transport_server.py +++ /dev/null @@ -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 diff --git a/server/world_state_presets.py b/server/world_state_presets.py deleted file mode 100644 index 711d897..0000000 --- a/server/world_state_presets.py +++ /dev/null @@ -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}" diff --git a/src/ServerProcess.cpp b/src/ServerProcess.cpp index 06bdd30..85aacb1 100644 --- a/src/ServerProcess.cpp +++ b/src/ServerProcess.cpp @@ -1,17 +1,26 @@ #include "ServerProcess.h" + #include -#include -#include -#include #include #include #include #include +#include +#include +#include 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(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) { diff --git a/src/ServerProcess.h b/src/ServerProcess.h index 85cb7ff..06de799 100644 --- a/src/ServerProcess.h +++ b/src/ServerProcess.h @@ -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