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 new file mode 100644 index 0000000..396449b --- /dev/null +++ b/.github/workflows/csharp-server.yml @@ -0,0 +1,51 @@ +name: CSharp Server Gate + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build-and-test: + name: Repository policy, C# build and test + runs-on: [self-hosted, Linux, X64] + steps: + - uses: actions/checkout@v4 + + - name: Enforce repository runtime policy + run: bash server/scripts/verify-no-legacy-runtime.sh + + - uses: actions/setup-dotnet@v4 + # The runner user cannot write to system /usr/share/dotnet; install the + # pinned SDK into a runner-writable, cached path instead. runner.* context + # is only valid at step scope, not job-level env. + env: + DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet + with: + dotnet-version: "8.0.x" + + - 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 + + - name: Check Linux scripts + working-directory: server + run: | + chmod +x start.sh fix-port.sh scripts/linux_compat_checks.sh scripts/verify-no-legacy-runtime.sh + bash -n start.sh + bash -n fix-port.sh + bash -n scripts/linux_compat_checks.sh + bash -n scripts/verify-no-legacy-runtime.sh + bash 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/gns-transport.yml b/.github/workflows/gns-transport.yml new file mode 100644 index 0000000..b63b596 --- /dev/null +++ b/.github/workflows/gns-transport.yml @@ -0,0 +1,50 @@ +name: GNS Transport Bridge + +on: + push: + paths: + - "server/native_transport/**" + - ".github/workflows/gns-transport.yml" + pull_request: + paths: + - "server/native_transport/**" + - ".github/workflows/gns-transport.yml" + +jobs: + linux: + name: Linux native GNS bridge + runs-on: [self-hosted, Linux, X64] + steps: + - uses: actions/checkout@v4 + + - name: Verify build dependencies (pre-provisioned on the self-hosted runner) + run: | + missing=0 + for tool in cmake ninja protoc; do + command -v "$tool" >/dev/null 2>&1 || { echo "::error::missing build dependency: $tool"; missing=1; } + done + test -f /usr/include/openssl/ssl.h || { echo "::error::missing libssl-dev headers"; missing=1; } + test "$missing" -eq 0 + + - name: Fetch pinned GameNetworkingSockets + run: | + GNS_SHA=f4525e39ee10f6b45181fd92d01fee75f7d71756 + rm -rf /tmp/co-gns-src + git init /tmp/co-gns-src + git -C /tmp/co-gns-src remote add origin https://github.com/ValveSoftware/GameNetworkingSockets.git + git -C /tmp/co-gns-src fetch --depth 1 origin "$GNS_SHA" + git -C /tmp/co-gns-src checkout --detach FETCH_HEAD + test "$(git -C /tmp/co-gns-src rev-parse HEAD)" = "$GNS_SHA" + + - name: Build and run native bridge tests + run: | + cmake -S server/native_transport -B /tmp/co-server-gns-build -G Ninja \ + -DGNS_SOURCE_DIR=/tmp/co-gns-src \ + -DCMAKE_BUILD_TYPE=Release + cmake --build /tmp/co-server-gns-build -j2 + ctest --test-dir /tmp/co-server-gns-build --output-on-failure + + - name: Confirm shared bridge artifact + run: | + test -f /tmp/co-server-gns-build/libcommonwealth_online_gns_bridge.so + ldd /tmp/co-server-gns-build/libcommonwealth_online_gns_bridge.so diff --git a/.github/workflows/linux-compatibility.yml b/.github/workflows/linux-compatibility.yml deleted file mode 100644 index 6e6197b..0000000 --- a/.github/workflows/linux-compatibility.yml +++ /dev/null @@ -1,108 +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 LF and executable bit - run: | - python3 - <<'PY' - from pathlib import Path - data = Path("start.sh").read_bytes() - assert b"\r\n" not in data, "start.sh must use LF line endings" - PY - test -x start.sh - - - name: Syntax and shellcheck - run: | - bash -n start.sh - shellcheck start.sh - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Create clean venv and install server deps - run: | - python -m venv .venv - .venv/bin/python -m pip install --upgrade pip - .venv/bin/python -m pip install -r requirements-server.txt pytest - # Ensure GUI-only deps are not required for dedicated server - ! .venv/bin/python -c "import PySide6" 2>/dev/null - - - name: Compile and test - run: | - .venv/bin/python -m compileall -q . - .venv/bin/python -m pytest -q - .venv/bin/python test_npc_protocol.py - .venv/bin/python test_combat_protocol.py - - arch: - name: Arch Linux container - runs-on: ubuntu-latest - container: - image: archlinux:latest - defaults: - run: - working-directory: server - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install packages - run: | - pacman -Syu --noconfirm python python-pip shellcheck - - - name: Verify start.sh LF and executable bit - run: | - python - <<'PY' - from pathlib import Path - data = Path("start.sh").read_bytes() - assert b"\r\n" not in data, "start.sh must use LF line endings" - PY - test -x start.sh - - - name: Syntax and shellcheck - run: | - bash -n start.sh - shellcheck start.sh - - - name: Create clean venv and install server deps - run: | - python -m venv .venv - .venv/bin/python -m pip install --upgrade pip - .venv/bin/python -m pip install -r requirements-server.txt pytest - - - name: Compile and test - run: | - .venv/bin/python -m compileall -q . - .venv/bin/python -m pytest -q - .venv/bin/python test_npc_protocol.py - .venv/bin/python test_combat_protocol.py diff --git a/.gitignore b/.gitignore index 3225677..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,22 +42,19 @@ 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 *.tmp *.bak logs/ +bans.json +server/bans.json +.admin-token +server/.admin-token .DS_Store Thumbs.db 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..3be31bf 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 staging and deployment checks reject prohibited legacy runtime artifacts before packaging succeeds. -## 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..46e7834 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. 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 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..f9862c3 100644 --- a/build.bat +++ b/build.bat @@ -1,132 +1,55 @@ -@echo off -setlocal enabledelayedexpansion - -echo. -echo ================================================================================ -echo Commonwealth Online - Qt GUI Build Script (with Qt6 Auto-Detection) -echo ================================================================================ -echo. - -REM Check if CMake is installed -cmake --version >nul 2>&1 -if errorlevel 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 -) - -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 -set PATHS_TO_CHECK[3]=C:\Qt\6.7.0\msvc2022_64 -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 - ) -) - -: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 - exit /b 1 -) - -REM Create build directory -if not exist "build" ( - echo. - echo Creating build directory... - mkdir build -) - -cd build - -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 -) - -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 off +setlocal enabledelayedexpansion + +echo. +echo ================================================================================ +echo Commonwealth Online - Qt Host and C# Server Build +echo ================================================================================ +echo. + +cmake --version >nul 2>&1 || ( + echo ERROR: CMake is not installed or not in PATH. + 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 +) + +set QT6_PATH= +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 +set PATHS_TO_CHECK[3]=C:\Qt\6.7.0\msvc2022_64 +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,6) do ( + if exist "!PATHS_TO_CHECK[%%i]!\lib\cmake\Qt6" if "!QT6_PATH!"=="" set QT6_PATH=!PATHS_TO_CHECK[%%i]! +) +if "!QT6_PATH!"=="" ( + echo ERROR: Qt6 not found at standard C:\Qt paths. + exit /b 1 +) + +echo Qt6: !QT6_PATH! +echo .NET: +dotnet --version + +dotnet build server\CommonwealthOnline.Server.csproj -c Release --nologo || exit /b 1 +dotnet run --project server\tests\CommonwealthOnline.Server.Tests.csproj -c Release || 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 Host GUI: build\bin\Release\CommonwealthOnlineHost.exe +echo CMake also published and staged the self-contained C# server beside the host. +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 b826490..2d590a5 100644 --- a/cmake/stage_server.cmake +++ b/cmake/stage_server.cmake @@ -1,11 +1,9 @@ -# Stage server/ next to the Host GUI, preserving an existing local config. -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 "") @@ -13,34 +11,59 @@ if(EXISTS "${CO_SERVER_STAGE_CONFIG}") file(READ "${CO_SERVER_STAGE_CONFIG}" _preserve_config) endif() +set(_stage_bans "${CO_SERVER_STAGE_DIR}/bans.json") +set(_preserve_bans "") +if(EXISTS "${_stage_bans}") + file(READ "${_stage_bans}" _preserve_bans) +endif() + +set(_stage_admin_token "${CO_SERVER_STAGE_DIR}/.admin-token") +set(_preserve_admin_token "") +if(EXISTS "${_stage_admin_token}") + file(READ "${_stage_admin_token}" _preserve_admin_token) +endif() + file(REMOVE_RECURSE "${CO_SERVER_STAGE_DIR}") file(MAKE_DIRECTORY "${CO_SERVER_STAGE_DIR}") - -# Prefer a filtered copy so Python caches do not ship with the build. -file(GLOB _server_entries RELATIVE "${CO_SERVER_SOURCE_DIR}" "${CO_SERVER_SOURCE_DIR}/*") -foreach(_entry IN LISTS _server_entries) - if(_entry STREQUAL "__pycache__" - OR _entry STREQUAL ".pytest_cache" - OR _entry STREQUAL ".venv" - OR _entry STREQUAL "logs" - OR _entry STREQUAL "tests") - 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 - ) -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 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 "${_preserve_bans}" STREQUAL "") + file(WRITE "${_stage_bans}" "${_preserve_bans}") +endif() + +if(NOT "${_preserve_admin_token}" STREQUAL "") + file(WRITE "${_stage_admin_token}" "${_preserve_admin_token}") +endif() + +file(GLOB_RECURSE _prohibited_legacy_files + LIST_DIRECTORIES false + "${CO_SERVER_STAGE_DIR}/*.py" + "${CO_SERVER_STAGE_DIR}/*.pyw" + "${CO_SERVER_STAGE_DIR}/*.pyi" + "${CO_SERVER_STAGE_DIR}/*.pyc" + "${CO_SERVER_STAGE_DIR}/*.pyo" + "${CO_SERVER_STAGE_DIR}/*.whl" + "${CO_SERVER_STAGE_DIR}/*.egg" + "${CO_SERVER_STAGE_DIR}/requirements*.txt" + "${CO_SERVER_STAGE_DIR}/Pipfile" + "${CO_SERVER_STAGE_DIR}/Pipfile.lock" + "${CO_SERVER_STAGE_DIR}/pyproject.toml" + "${CO_SERVER_STAGE_DIR}/poetry.lock" + "${CO_SERVER_STAGE_DIR}/setup.py" + "${CO_SERVER_STAGE_DIR}/setup.cfg" + "${CO_SERVER_STAGE_DIR}/tox.ini" +) +if(_prohibited_legacy_files) + string(JOIN "\n " _prohibited_list ${_prohibited_legacy_files}) + message(FATAL_ERROR "Prohibited legacy runtime artifacts were staged:\n ${_prohibited_list}") +endif() + +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..8e01dbb 100644 --- a/deploy.bat +++ b/deploy.bat @@ -1,105 +1,57 @@ -@echo off -setlocal enabledelayedexpansion - -echo. -echo ================================================================================ -echo Commonwealth Online GUI - Qt6 DLL 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 "!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 - -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) - ) -) - -REM Copy plugins directory -if not exist "!DEPLOY_DIR!\plugins" mkdir "!DEPLOY_DIR!\plugins" - -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 - -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. -) - -echo. -echo ================================================================================ -echo Deployment Complete! -echo ================================================================================ -echo. -echo Executable is now ready to run: -echo !DEPLOY_DIR!\CommonwealthOnlineHost.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 off +setlocal enabledelayedexpansion + +echo. +echo ================================================================================ +echo Commonwealth Online - Qt Host Deployment +echo ================================================================================ +echo. + +set QT6_PATH= +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. + exit /b 1 +) + +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 +) + +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 +) + +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 + +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 +) + +set PROHIBITED_FOUND=0 +for /r "!SERVER_DIR!" %%F in (*.py *.pyw *.pyi *.pyc *.pyo *.whl *.egg requirements*.txt Pipfile Pipfile.lock pyproject.toml poetry.lock setup.py setup.cfg tox.ini) do ( + if exist "%%F" ( + echo ERROR: Prohibited legacy runtime artifact found in staged package: %%F + set PROHIBITED_FOUND=1 + ) +) +if "!PROHIBITED_FOUND!"=="1" exit /b 1 + +echo. +echo Deployment complete: +echo !DEPLOY_DIR!\CommonwealthOnlineHost.exe +echo !SERVER_DIR!\CommonwealthOnline.Server.exe +echo. +echo Copy the entire Release directory when distributing. The bundled server is self-contained. +exit /b 0 diff --git a/server/AdminDiscovery.cs b/server/AdminDiscovery.cs new file mode 100644 index 0000000..1c42ee7 --- /dev/null +++ b/server/AdminDiscovery.cs @@ -0,0 +1,311 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal static class AdminTokenStore +{ + public static string LoadOrCreate(string path) + { + var full = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + if (File.Exists(full)) + { + var existing = File.ReadAllText(full, Encoding.UTF8).Trim(); + if (!string.IsNullOrEmpty(existing)) return existing; + } + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var temp = Path.Combine(Path.GetDirectoryName(full)!, $".{Path.GetFileName(full)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + try + { + using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(false))) + { + writer.WriteLine(token); + writer.Flush(); + stream.Flush(true); + } + BanStore.TryRestrictPermissions(temp); + File.Move(temp, full, true); + BanStore.TryRestrictPermissions(full); + return token; + } + catch + { + try { File.Delete(temp); } catch { } + if (File.Exists(full)) + { + var existing = File.ReadAllText(full, Encoding.UTF8).Trim(); + if (!string.IsNullOrEmpty(existing)) return existing; + } + throw; + } + } + + public static string Load(string path) + { + var token = File.ReadAllText(Path.GetFullPath(path), Encoding.UTF8).Trim(); + return string.IsNullOrEmpty(token) ? throw new InvalidOperationException($"Admin token file is empty: {path}") : token; + } + + public static bool EqualsConstantTime(string supplied, string expected) + { + var left = Encoding.UTF8.GetBytes(supplied); + var right = Encoding.UTF8.GetBytes(expected); + return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right); + } +} + +internal sealed class AdminControlServer : IAsyncDisposable +{ + private readonly AuthoritativeServer _server; + private readonly ServerOptions _options; + private readonly string _token; + private readonly CancellationTokenSource _shutdown = new(); + private readonly List _clientTasks = new(); + private readonly object _gate = new(); + private TcpListener? _listener; + private Task? _acceptTask; + + public AdminControlServer(AuthoritativeServer server, ServerOptions options) + { + _server = server; + _options = options; + _token = AdminTokenStore.LoadOrCreate(options.AdminTokenPath); + } + + public void Start() + { + _listener = new TcpListener(IPAddress.Loopback, _options.AdminPort); + _listener.Start(); + _acceptTask = Task.Run(() => AcceptLoopAsync(_shutdown.Token)); + _server.Log($"Admin control listening on 127.0.0.1:{_options.AdminPort} (localhost, authenticated)"); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client; + try { client = await _listener!.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException) { if (cancellationToken.IsCancellationRequested) break; else continue; } + var task = HandleClientAsync(client, cancellationToken); + lock (_gate) _clientTasks.Add(task); + _ = task.ContinueWith(_ => { lock (_gate) _clientTasks.Remove(task); }, TaskScheduler.Default); + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) + { + using (client) + { + var stream = client.GetStream(); + var read = new byte[4096]; + using var line = new MemoryStream(); + try + { + while (!cancellationToken.IsCancellationRequested) + { + var count = await stream.ReadAsync(read, cancellationToken).ConfigureAwait(false); + if (count == 0) break; + for (var i = 0; i < count; i++) + { + if (read[i] == (byte)'\n') + { + var data = line.ToArray(); line.SetLength(0); + if (data.Length == 0) continue; + var response = data.Length > ProtocolConstants.MaxMessageBytes + ? Fail("Admin request is too large.") + : await DispatchAsync(data).ConfigureAwait(false); + var encoded = JsonSerializer.SerializeToUtf8Bytes(response); + await stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); + continue; + } + line.WriteByte(read[i]); + if (line.Length > ProtocolConstants.MaxMessageBytes) return; + } + } + } + catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException) { } + } + } + + private async Task DispatchAsync(byte[] data) + { + JsonObject request; + try { request = JsonNode.Parse(data) as JsonObject ?? throw new JsonException("request must be an object"); } + catch (JsonException ex) { return Fail($"Invalid JSON: {ex.Message}"); } + var supplied = JsonHelpers.String(request["adminToken"]); + request.Remove("adminToken"); + if (supplied is null || !AdminTokenStore.EqualsConstantTime(supplied, _token)) return Fail("Unauthorized admin request."); + var command = (JsonHelpers.String(request["cmd"] ?? request["command"]) ?? string.Empty).Trim().ToLowerInvariant(); + var id = request["id"]?.DeepClone(); + + JsonObject Ok(JsonNode? body = null, string? message = null) + { + var response = new JsonObject { ["ok"] = true }; + if (id is not null) response["id"] = id.DeepClone(); + if (!string.IsNullOrEmpty(message)) response["message"] = message; + if (body is not null) response["data"] = body; + return response; + } + JsonObject LocalFail(string error) + { + var response = Fail(error); + if (id is not null) response["id"] = id.DeepClone(); + return response; + } + + switch (command) + { + case "ping": return Ok(new JsonObject { ["pong"] = true }); + case "stats": case "status": return Ok(_server.GetAdminStats()); + case "clients": case "users": + { + var clients = _server.GetAdminClients(); + return Ok(new JsonObject { ["total_clients"] = clients.Count, ["clients"] = clients }); + } + case "bans": + { + var bans = new JsonArray(_server.ListBans().Select(x => (JsonNode)new JsonObject { ["ip"] = x.Ip, ["reason"] = x.Reason, ["bannedAt"] = x.BannedAt }).ToArray()); + return Ok(new JsonObject { ["bans"] = bans }); + } + case "kick": + { + if (!JsonHelpers.TryUInt32(request["playerId"] ?? request["player_id"], 1, uint.MaxValue, out var playerId)) return LocalFail("kick requires playerId"); + var result = await _server.KickAsync(playerId, JsonHelpers.String(request["reason"]) ?? string.Empty).ConfigureAwait(false); + return result.Ok ? Ok(result.Data, result.Message) : LocalFail(result.Message); + } + case "ban": + { + var reason = JsonHelpers.String(request["reason"]) ?? string.Empty; + if (JsonHelpers.TryUInt32(request["playerId"] ?? request["player_id"], 1, uint.MaxValue, out var playerId)) + { + var result = await _server.BanPlayerAsync(playerId, reason).ConfigureAwait(false); + return result.Ok ? Ok(result.Data, result.Message) : LocalFail(result.Message); + } + var ip = JsonHelpers.String(request["ip"]); + if (string.IsNullOrWhiteSpace(ip)) return LocalFail("ban requires playerId or ip"); + var ban = await _server.BanIpAsync(ip, reason).ConfigureAwait(false); + return ban.Ok ? Ok(ban.Data, ban.Message) : LocalFail(ban.Message); + } + case "unban": + { + var ip = JsonHelpers.String(request["ip"]); + if (string.IsNullOrWhiteSpace(ip)) return LocalFail("unban requires ip"); + var result = _server.Unban(ip); + return result.Ok ? Ok(new JsonObject { ["ip"] = ip }, result.Message) : LocalFail(result.Message); + } + case "world_time": + { + var hhmm = JsonHelpers.String(request["hhmm"] ?? request["time"]); + if (string.IsNullOrWhiteSpace(hhmm)) return LocalFail("world_time requires hhmm"); + return await _server.SetServerTimeAsync(hhmm).ConfigureAwait(false) ? Ok(message: $"Server time set to {hhmm}.") : LocalFail("Invalid time format. Use HHmm (e.g., 1430 for 14:30)."); + } + case "world_weather": + { + var weather = JsonHelpers.String(request["weather"] ?? request["fw"]); + if (string.IsNullOrWhiteSpace(weather)) return LocalFail("world_weather requires weather"); + return await _server.SetServerWeatherAsync(weather).ConfigureAwait(false) ? Ok(message: $"Server weather updated to {weather}.") : LocalFail("Invalid weather ID. Use an 8-digit hex form ID."); + } + default: return LocalFail($"Unknown admin command: {(string.IsNullOrEmpty(command) ? "(empty)" : command)}"); + } + } + + private static JsonObject Fail(string error) => new() { ["ok"] = false, ["error"] = error }; + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + try { _listener?.Stop(); } catch { } + if (_acceptTask is not null) { try { await _acceptTask.ConfigureAwait(false); } catch { } } + Task[] tasks; lock (_gate) tasks = _clientTasks.ToArray(); + if (tasks.Length > 0) { try { await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2)); } catch { } } + _shutdown.Dispose(); + } +} + +internal static class AdminClient +{ + public static async Task SendAsync(JsonObject request, int port, string tokenPath, CancellationToken cancellationToken = default) + { + var authenticated = (JsonObject)request.DeepClone(); + authenticated["adminToken"] = AdminTokenStore.Load(tokenPath); + var encoded = JsonSerializer.SerializeToUtf8Bytes(authenticated); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, cancellationToken).ConfigureAwait(false); + var stream = client.GetStream(); + await stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); + using var line = new MemoryStream(); + var one = new byte[1]; + while (line.Length <= ProtocolConstants.MaxMessageBytes) + { + var count = await stream.ReadAsync(one, cancellationToken).ConfigureAwait(false); + if (count == 0) throw new IOException("Admin server closed the connection without a response."); + if (one[0] == (byte)'\n') break; + line.WriteByte(one[0]); + } + if (line.Length > ProtocolConstants.MaxMessageBytes) throw new InvalidDataException("Admin response exceeded the maximum size."); + return JsonNode.Parse(line.ToArray()) as JsonObject ?? throw new InvalidDataException("Admin response must be a JSON object."); + } +} + +internal sealed class LanDiscoveryService : IAsyncDisposable +{ + public const int DiscoveryPort = 7778; + public const string ProtocolName = "commonwealth-online"; + private readonly AuthoritativeServer _server; + private readonly ServerOptions _options; + private readonly CancellationTokenSource _shutdown = new(); + private UdpClient? _udp; + private Task? _task; + + public LanDiscoveryService(AuthoritativeServer server, ServerOptions options) { _server = server; _options = options; } + + public void Start() + { + _udp = new UdpClient(new IPEndPoint(IPAddress.Any, DiscoveryPort)); + _udp.EnableBroadcast = true; + _task = Task.Run(() => RunAsync(_shutdown.Token)); + _server.Log($"LAN discovery listening on UDP port {DiscoveryPort}"); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + UdpReceiveResult result; + try { result = await _udp!.ReceiveAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException) { continue; } + JsonObject? request; + try { request = JsonNode.Parse(result.Buffer) as JsonObject; } catch (JsonException) { continue; } + if (request is null || JsonHelpers.String(request["type"]) != "discover" || JsonHelpers.String(request["protocol"]) != ProtocolName) continue; + var core = _server.GetCoreStats(); + var response = new JsonObject + { + ["type"] = "discoverResponse", ["protocol"] = ProtocolName, ["version"] = 1, ["name"] = _options.ServerName, + ["description"] = _options.ServerDescription, ["port"] = _options.Port, ["players"] = core["connectedClients"]?.DeepClone(), ["maxPlayers"] = _options.MaxPlayers + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(response); + try { await _udp.SendAsync(bytes, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); } catch { } + } + } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + _udp?.Dispose(); + if (_task is not null) { try { await _task.ConfigureAwait(false); } catch { } } + _shutdown.Dispose(); + } +} diff --git a/server/AssemblyInfo.cs b/server/AssemblyInfo.cs new file mode 100644 index 0000000..f325ae5 --- /dev/null +++ b/server/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CommonwealthOnline.Server.Tests")] diff --git a/server/AuthoritativeServer.cs b/server/AuthoritativeServer.cs new file mode 100644 index 0000000..2dd3686 --- /dev/null +++ b/server/AuthoritativeServer.cs @@ -0,0 +1,836 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal sealed class AuthoritativeServer : IServerIngress, IAsyncDisposable +{ + private const int MaxPacketsPerSecond = 120; + private const int MaxConnectAttempts = 8; + private const double ConnectAttemptWindowSeconds = 10.0; + private const double ClientIdleTimeoutSeconds = 60.0; + private const double ClientHandshakeTimeoutSeconds = 10.0; + + private readonly ServerOptions _options; + private readonly BanStore _banStore; + private readonly object _gate = new(); + private readonly Dictionary _clients = new(StringComparer.Ordinal); + private readonly Dictionary _lastPlayerStateByPlayerId = new(); + private readonly Dictionary _lastNpcStateByScope = new(); + private readonly NpcAuthorityManager _npcAuthority = new(); + private readonly Dictionary> _connectAttempts = new(StringComparer.Ordinal); + private readonly Dictionary _stats = new(StringComparer.Ordinal); + private readonly Dictionary _serverWorldState = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _shutdown = new(); + private readonly Task _maintenanceTask; + private JsonObject? _lastLegacyNpcState; + private uint _nextPlayerId = 1; + private uint? _worldStateHostPlayerId; + private readonly double _startedAt = JsonHelpers.UnixTime(); + + public AuthoritativeServer(ServerOptions options) + { + _options = options; + _banStore = new BanStore(options.BansPath); + foreach (var name in new[] + { + "clientsConnected", "clientsDisconnected", "pendingConnectionsRejected", "packetsReceived", "packetsSent", "packetsBroadcast", + "packetsRejected", "rateLimitedPackets", "transformPacketsReceived", "transformPacketsBroadcast", "transformPacketsInterestFiltered", + "playerStatePacketsReceived", "playerStatePacketsBroadcast", "movementPacketsRejected", "movementCorrectionsSent", + "worldStatePacketsReceived", "worldStatePacketsBroadcast", "npcStatePacketsReceived", "npcStatePacketsBroadcast", + "npcAuthorityChanges", "npcAuthorityRejects", "combatHitsReceived", "combatHitsRouted", "worldStateHostPacketsBroadcast", + "serverWorldStatePacketsBroadcast", "sessionEndedPacketsSent", "bannedConnectionsRejected", "disconnectPacketsBroadcast", + "protocolV2Connections", "legacyConnections" + }) _stats[name] = 0; + _maintenanceTask = Task.Run(() => MaintenanceLoopAsync(_shutdown.Token)); + } + + public event Action? LogMessage; + + public void Log(string message, string level = "info") + { + if (!ShouldLog(level)) return; + LogMessage?.Invoke(message, level); + } + + private bool ShouldLog(string level) + { + static int Rank(string value) => value switch { "debug" => 10, "info" => 20, "warning" => 30, "error" => 40, _ => 20 }; + return Rank(level) >= Rank(_options.LogVerbosity); + } + + public async Task AcceptConnectionAsync(IGameConnection connection, CancellationToken cancellationToken) + { + var ip = connection.RemoteEndpoint.Address.ToString(); + var ban = _banStore.GetBan(ip); + if (ban is not null) + { + Increment("bannedConnectionsRejected"); + await SendDirectSessionEndedAsync(connection, "banned", ban.Value.Reason, cancellationToken).ConfigureAwait(false); + await connection.DisconnectAsync(0, "Banned"); + return false; + } + if (!AllowConnectAttempt(ip)) + { + Increment("pendingConnectionsRejected"); + await SendDirectSessionEndedAsync(connection, "rate_limited", "Too many connection attempts.", cancellationToken).ConfigureAwait(false); + await connection.DisconnectAsync(0, "Connection attempt rate limited"); + return false; + } + + ClientSession client; + lock (_gate) + { + var maxPending = Math.Max(16, _options.MaxPlayers * 2); + var pending = _clients.Values.Count(x => !x.GameplayActive); + if (pending >= maxPending) client = null!; + else + { + client = new ClientSession(connection, _nextPlayerId++); + _clients[connection.ConnectionKey] = client; + } + } + if (client is null) + { + Increment("pendingConnectionsRejected"); + await SendDirectSessionEndedAsync(connection, "rate_limited", "Too many pending connections.", cancellationToken).ConfigureAwait(false); + await connection.DisconnectAsync(0, "Too many pending connections"); + return false; + } + + var capabilities = new JsonArray("interest-v1", "hello-v2", "bounded-framing", "rate-limit-v1", "movement-correction-v1", "npc-authority-epoch-v1", "player-state-v1"); + if (connection.TransportName == "gns") + { + capabilities.Add("gns-message-transport-v1"); + capabilities.Add("gns-snapshot-sequence-v1"); + } + var welcome = new JsonObject + { + ["type"] = "welcome", + ["playerId"] = client.PlayerId, + ["serverTime"] = JsonHelpers.UnixTime(), + ["serverName"] = _options.ServerName, + ["serverDescription"] = _options.ServerDescription, + ["protocolVersion"] = ProtocolConstants.ProtocolVersion, + ["capabilities"] = capabilities + }; + Log($"{connection.TransportName.ToUpperInvariant()} accept: {client.Label} (provisional player {client.PlayerId})", "debug"); + if (!await SendPacketAsync(client, welcome, false, cancellationToken).ConfigureAwait(false)) + { + await DisconnectClientAsync(client).ConfigureAwait(false); + return false; + } + return true; + } + + public async Task HandleMessageAsync(IGameConnection connection, ReadOnlyMemory payload, CancellationToken cancellationToken) + { + var client = FindByConnection(connection); + if (client is null) { await connection.DisconnectAsync(0, "Unknown session"); return; } + if (!await AllowPacketAsync(client).ConfigureAwait(false)) return; + client.RecordReceived(); + Increment("packetsReceived"); + + JsonObject packet; + try { packet = PacketCodec.Decode(payload.Span); } + catch (PacketCodecException ex) { Reject(client, ex.Message); return; } + await DispatchPacketAsync(client, packet, cancellationToken).ConfigureAwait(false); + } + + public async Task HandleTransportRejectAsync(IGameConnection connection, string reason, bool warning = false) + { + var client = FindByConnection(connection); + if (client is null) return; + if (!await AllowPacketAsync(client).ConfigureAwait(false)) return; + client.RecordReceived(); + Increment("packetsReceived"); + Reject(client, reason, warning); + } + + public Task HandleConnectionClosedAsync(IGameConnection connection) + { + var client = FindByConnection(connection); + return client is null ? Task.CompletedTask : DisconnectClientAsync(client); + } + + public async Task EndSessionForTransportAsync(IGameConnection connection, string code, string reason) + { + var client = FindByConnection(connection); + if (client is null) + { + await SendDirectSessionEndedAsync(connection, code, reason, CancellationToken.None).ConfigureAwait(false); + await connection.DisconnectAsync(0, reason); + return; + } + await EndSessionAsync(client, code, reason).ConfigureAwait(false); + } + + private async Task DispatchPacketAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + var packetType = JsonHelpers.String(packet["type"]); + if (packetType == "hello") { await HandleHelloAsync(client, packet, cancellationToken).ConfigureAwait(false); return; } + if (packetType == "keepAlive") return; + + if (!client.GameplayActive) + { + if (packetType is not ("transform" or "worldState" or "npcState" or "combatHit")) + { + Reject(client, "Gameplay packet received before session activation"); + return; + } + if (!await ActivateClientAsync(client, ProtocolConstants.LegacyProtocolVersion, cancellationToken).ConfigureAwait(false)) + { + await EndSessionAsync(client, "server_full", "Server is full.").ConfigureAwait(false); + return; + } + Log($"Legacy client player {client.PlayerId} activated without hello; client upgrade recommended.", "warning"); + } + + switch (packetType) + { + case "transform": await HandleTransformAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + case "playerState": await HandlePlayerStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + case "worldState": await HandleWorldStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + case "npcState": await HandleNpcStateAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + case "combatHit": await HandleCombatHitAsync(client, packet, cancellationToken).ConfigureAwait(false); break; + default: Reject(client, $"Unknown packet type: {packetType ?? ""}"); break; + } + } + + private async Task HandleHelloAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + if (!JsonHelpers.TryUInt32(packet["protocolVersion"], 1, ushort.MaxValue, out var version) || version != ProtocolConstants.ProtocolVersion) + { + await EndSessionAsync(client, "protocol_mismatch", $"Server requires protocol {ProtocolConstants.ProtocolVersion}.").ConfigureAwait(false); + return; + } + if (!await ActivateClientAsync(client, (int)version, cancellationToken).ConfigureAwait(false)) + await EndSessionAsync(client, "server_full", "Server is full.").ConfigureAwait(false); + } + + private async Task ActivateClientAsync(ClientSession client, int protocolVersion, CancellationToken cancellationToken) + { + bool becameHost; + lock (_gate) + { + if (client.GameplayActive) return true; + if (_clients.Values.Count(x => x.GameplayActive) >= _options.MaxPlayers) return false; + if (!client.Activate(protocolVersion)) return true; + _stats["clientsConnected"]++; + _stats[protocolVersion >= ProtocolConstants.ProtocolVersion ? "protocolV2Connections" : "legacyConnections"]++; + becameHost = _worldStateHostPlayerId is null; + if (becameHost) _worldStateHostPlayerId = client.PlayerId; + } + + Log($"Client connected: {client.Label} (player {client.PlayerId}, protocol {protocolVersion})"); + var ready = new JsonObject + { + ["type"] = "sessionReady", ["playerId"] = client.PlayerId, ["protocolVersion"] = protocolVersion, + ["serverProtocolVersion"] = ProtocolConstants.ProtocolVersion, ["worldStateHostPlayerId"] = _worldStateHostPlayerId, + ["serverTime"] = JsonHelpers.UnixTime() + }; + if (!await SendPacketAsync(client, ready, false, cancellationToken).ConfigureAwait(false)) return false; + await SendExistingTransformsAsync(client, cancellationToken).ConfigureAwait(false); + await SendExistingPlayerStatesAsync(client, cancellationToken).ConfigureAwait(false); + await SendExistingNpcStateAsync(client, cancellationToken).ConfigureAwait(false); + if (becameHost) await BroadcastWorldStateHostAsync(client.PlayerId, cancellationToken).ConfigureAwait(false); + return true; + } + + private async Task HandleTransformAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + var normalized = ProtocolValidation.NormalizeTransform(packet); + if (normalized is null) { Reject(client, "Malformed transform"); return; } + if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion) + foreach (var field in new[] { "equippedItems", "appearance", "actionEvents", "characterName" }) normalized.Remove(field); + var previous = client.TransformAnchor(); + var acceptedMonotonic = MonotonicClock.Now; + var movement = ProtocolValidation.ValidateMovement(previous.Transform, previous.Monotonic, normalized, acceptedMonotonic); + if (!movement.Accepted) + { + Increment("movementPacketsRejected"); + Reject(client, movement.Reason); + await SendPositionCorrectionAsync(client, movement.Reason, cancellationToken).ConfigureAwait(false); + return; + } + var previousScope = AuthorityScopeFromTransform(previous.Transform); + normalized["playerId"] = client.PlayerId; + normalized["serverTime"] = JsonHelpers.UnixTime(); + client.RecordTransform(normalized, acceptedMonotonic); + Increment("transformPacketsReceived"); + await BroadcastTransformAsync(client, normalized, cancellationToken).ConfigureAwait(false); + await ReconcileNpcAuthorityAsync(cancellationToken).ConfigureAwait(false); + var currentScope = AuthorityScopeFromTransform(normalized); + if (currentScope is not null && currentScope != previousScope) await SendNpcAuthorityForClientAsync(client, currentScope.Value, cancellationToken).ConfigureAwait(false); + } + + private async Task HandlePlayerStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + if (client.ProtocolVersion < ProtocolConstants.ProtocolVersion) { Reject(client, "playerState requires Protocol V2", false); return; } + var normalized = ProtocolValidation.NormalizePlayerState(packet); + if (normalized is null) { Reject(client, "Malformed playerState"); return; } + normalized["playerId"] = client.PlayerId; + normalized["serverTime"] = JsonHelpers.UnixTime(); + var durable = new[] { "equippedItems", "appearance", "characterName" }; + lock (_gate) + { + if (durable.Any(normalized.ContainsKey)) + { + var cached = _lastPlayerStateByPlayerId.TryGetValue(client.PlayerId, out var existing) ? JsonHelpers.CloneObject(existing) : new JsonObject(); + cached["type"] = "playerState"; cached["playerId"] = client.PlayerId; cached["serverTime"] = normalized["serverTime"]?.DeepClone(); + foreach (var field in durable) if (normalized.ContainsKey(field)) cached[field] = normalized[field]?.DeepClone(); + cached.Remove("actionEvents"); + _lastPlayerStateByPlayerId[client.PlayerId] = cached; + } + _stats["playerStatePacketsReceived"]++; + } + await BroadcastPlayerStateAsync(client, normalized, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleWorldStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + lock (_gate) if (_worldStateHostPlayerId != client.PlayerId) { RejectLocked(client, "worldState from non-authority client", false); return; } + var normalized = ProtocolValidation.NormalizeWorldState(packet); + if (normalized is null) { Reject(client, "Malformed worldState"); return; } + normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime(); + Increment("worldStatePacketsReceived"); + await BroadcastWorldStateAsync(client, normalized, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleNpcStateAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + var normalized = ProtocolValidation.NormalizeNpcState(packet); + if (normalized is null) { Reject(client, "Malformed npcState"); return; } + if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion) + { + var scope = AuthorityScopeFromPacket(normalized); + if (scope is null || !JsonHelpers.TryUInt32(normalized["authorityEpoch"], 1, uint.MaxValue, out var epoch)) + { + Increment("npcAuthorityRejects"); Reject(client, "Protocol V2 npcState missing valid authority scope/epoch"); return; + } + lock (_gate) + { + if (!_npcAuthority.Authorize(client.PlayerId, scope.Value, epoch)) { _stats["npcAuthorityRejects"]++; RejectLocked(client, "Stale or unauthorized npcState authority epoch"); return; } + } + var npcs = (JsonArray)normalized["npcs"]!; + foreach (var node in npcs) + { + var npc = (JsonObject)node!; + if (JsonHelpers.String(npc["cellId"]) != scope.Value.CellId || (JsonHelpers.String(npc["worldspaceId"]) ?? string.Empty) != scope.Value.WorldspaceId) + { + Increment("npcAuthorityRejects"); Reject(client, "npcState contains NPCs outside declared authority scope"); return; + } + } + normalized["authorityCellId"] = scope.Value.CellId; normalized["authorityWorldspaceId"] = scope.Value.WorldspaceId; + lock (_gate) _lastNpcStateByScope[scope.Value] = JsonHelpers.CloneObject(normalized); + } + else + { + lock (_gate) if (_worldStateHostPlayerId != client.PlayerId) { RejectLocked(client, "Legacy npcState from non-authority client", false); return; } + lock (_gate) _lastLegacyNpcState = JsonHelpers.CloneObject(normalized); + } + normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime(); normalized["fullReplace"] = true; + Increment("npcStatePacketsReceived"); + await BroadcastNpcStateAsync(client, normalized, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleCombatHitAsync(ClientSession client, JsonObject packet, CancellationToken cancellationToken) + { + var normalized = ProtocolValidation.NormalizeCombatHit(packet); + if (normalized is null) { Reject(client, "Malformed combatHit"); return; } + JsonHelpers.TryUInt32(normalized["sequence"], 1, uint.MaxValue, out var sequence); + if (sequence <= client.LastCombatSequence) { Reject(client, "Duplicate or out-of-order combat sequence", false); return; } + client.LastCombatSequence = sequence; + normalized["playerId"] = client.PlayerId; normalized["serverTime"] = JsonHelpers.UnixTime(); + Increment("combatHitsReceived"); + await RouteCombatHitAsync(client, normalized, cancellationToken).ConfigureAwait(false); + } + + private async Task AllowPacketAsync(ClientSession client) + { + var now = MonotonicClock.Now; + if (now - client.RateWindowStart >= 1.0) + { + if (client.RateWindowCount <= MaxPacketsPerSecond) client.RateViolations = Math.Max(0, client.RateViolations - 1); + client.RateWindowStart = now; client.RateWindowCount = 0; client.RateWindowBlocked = false; + } + client.RateWindowCount++; + if (client.RateWindowCount <= MaxPacketsPerSecond) return true; + Increment("rateLimitedPackets"); + if (!client.RateWindowBlocked) + { + client.RateWindowBlocked = true; client.RateViolations++; + Log($"Packet-rate limit exceeded by player {client.PlayerId} ({client.RateViolations}/3 windows)", "warning"); + } + if (client.RateViolations >= 3) await EndSessionAsync(client, "rate_limited", "Sustained packet-rate limit exceeded.").ConfigureAwait(false); + return false; + } + + private bool AllowConnectAttempt(string ip) + { + var now = MonotonicClock.Now; + var cutoff = now - ConnectAttemptWindowSeconds; + lock (_gate) + { + if (!_connectAttempts.TryGetValue(ip, out var queue)) _connectAttempts[ip] = queue = new Queue(); + while (queue.Count > 0 && queue.Peek() < cutoff) queue.Dequeue(); + queue.Enqueue(now); + return queue.Count <= MaxConnectAttempts; + } + } + + private async Task SendPositionCorrectionAsync(ClientSession client, string reason, CancellationToken cancellationToken) + { + var previous = client.TransformAnchor().Transform; + if (previous is null) return; + var packet = new JsonObject + { + ["type"] = "positionCorrection", ["reason"] = reason[..Math.Min(reason.Length, 160)], + ["x"] = previous["x"]?.DeepClone(), ["y"] = previous["y"]?.DeepClone(), ["z"] = previous["z"]?.DeepClone(), + ["angleZ"] = previous["angleZ"]?.DeepClone(), ["cellId"] = previous["cellId"]?.DeepClone(), + ["worldspaceId"] = previous["worldspaceId"]?.DeepClone(), ["serverTime"] = JsonHelpers.UnixTime() + }; + if (await SendPacketAsync(client, packet, false, cancellationToken).ConfigureAwait(false)) Increment("movementCorrectionsSent"); + else await DisconnectClientAsync(client).ConfigureAwait(false); + } + + private async Task SendExistingTransformsAsync(ClientSession target, CancellationToken cancellationToken) + { + var targetTransform = target.TransformAnchor().Transform; + ClientSession[] peers; + lock (_gate) peers = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != target.Connection.ConnectionKey && x.LastTransform is not null).ToArray(); + var sent = 0; + foreach (var peer in peers) + { + var transform = peer.TransformAnchor().Transform; + if (!ProtocolValidation.StatesShareInterest(transform, targetTransform)) continue; + if (transform is null) continue; + transform["serverTime"] = JsonHelpers.UnixTime(); + if (!await SendPacketAsync(target, transform, true, cancellationToken).ConfigureAwait(false)) break; + sent++; + } + Add("transformPacketsBroadcast", sent); + } + + private async Task SendExistingPlayerStatesAsync(ClientSession target, CancellationToken cancellationToken) + { + if (target.ProtocolVersion < ProtocolConstants.ProtocolVersion) return; + JsonObject[] packets; + lock (_gate) packets = _lastPlayerStateByPlayerId.Where(x => x.Key != target.PlayerId).Select(x => JsonHelpers.CloneObject(x.Value)).ToArray(); + var sent = 0; + foreach (var packet in packets) + { + packet["serverTime"] = JsonHelpers.UnixTime(); + if (!await SendPacketAsync(target, packet, true, cancellationToken).ConfigureAwait(false)) break; + sent++; + } + Add("playerStatePacketsBroadcast", sent); + } + + private async Task BroadcastTransformAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + ClientSession[] recipients; + lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey).ToArray(); + var sent = 0; var filtered = 0; + foreach (var recipient in recipients) + { + if (!ProtocolValidation.StatesShareInterest(packet, recipient.TransformAnchor().Transform)) { filtered++; continue; } + if (await SendPacketAsync(recipient, packet, true, cancellationToken).ConfigureAwait(false)) sent++; + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + Add("transformPacketsBroadcast", sent); Add("transformPacketsInterestFiltered", filtered); + } + + private async Task BroadcastPlayerStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + ClientSession[] recipients; + lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey && x.ProtocolVersion >= ProtocolConstants.ProtocolVersion).ToArray(); + var durable = new[] { "equippedItems", "appearance", "characterName" }; + var senderTransform = sender.TransformAnchor().Transform; + var senderScopeKnown = ProtocolValidation.ScopeFromState(senderTransform) is not null; + var sent = 0; + foreach (var recipient in recipients) + { + var relay = JsonHelpers.CloneObject(packet); + if (relay.ContainsKey("actionEvents")) + { + var targetTransform = recipient.TransformAnchor().Transform; + var targetScopeKnown = ProtocolValidation.ScopeFromState(targetTransform) is not null; + if (!senderScopeKnown || !targetScopeKnown || !ProtocolValidation.StatesShareInterest(senderTransform, targetTransform)) relay.Remove("actionEvents"); + } + if (!relay.ContainsKey("actionEvents") && !durable.Any(relay.ContainsKey)) continue; + if (await SendPacketAsync(recipient, relay, true, cancellationToken).ConfigureAwait(false)) sent++; + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + Add("playerStatePacketsBroadcast", sent); + } + + private async Task BroadcastWorldStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + var sent = await BroadcastAsync(packet, sender, false, cancellationToken).ConfigureAwait(false); + Add("worldStatePacketsBroadcast", sent); + } + + private JsonObject NpcPacketForRecipient(JsonObject packet, ClientSession recipient) + { + var target = recipient.TransformAnchor().Transform; + if (target is null) return JsonHelpers.CloneObject(packet); + var clean = JsonHelpers.CloneObject(packet); + var output = new JsonArray(); + if (packet["npcs"] is JsonArray npcs) + foreach (var node in npcs) if (node is JsonObject npc && ProtocolValidation.StatesShareInterest(npc, target)) output.Add(npc.DeepClone()); + clean["npcs"] = output; + return clean; + } + + private async Task BroadcastNpcStateAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + ClientSession[] recipients; + lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && x.Connection.ConnectionKey != sender.Connection.ConnectionKey).ToArray(); + var sent = 0; + foreach (var recipient in recipients) + { + if (await SendPacketAsync(recipient, NpcPacketForRecipient(packet, recipient), true, cancellationToken).ConfigureAwait(false)) sent++; + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + Add("npcStatePacketsBroadcast", sent); + } + + private async Task SendExistingNpcStateAsync(ClientSession client, CancellationToken cancellationToken) + { + JsonObject? snapshot; + if (client.ProtocolVersion >= ProtocolConstants.ProtocolVersion) + { + var scope = AuthorityScopeFromTransform(client.TransformAnchor().Transform); + lock (_gate) snapshot = scope is not null && _lastNpcStateByScope.TryGetValue(scope.Value, out var stored) ? JsonHelpers.CloneObject(stored) : null; + } + else lock (_gate) snapshot = _lastLegacyNpcState is null ? null : JsonHelpers.CloneObject(_lastLegacyNpcState); + if (snapshot is null || (JsonHelpers.TryUInt32(snapshot["playerId"], 0, uint.MaxValue, out var owner) && owner == client.PlayerId)) return; + var scoped = NpcPacketForRecipient(snapshot, client); scoped["serverTime"] = JsonHelpers.UnixTime(); + if (await SendPacketAsync(client, scoped, true, cancellationToken).ConfigureAwait(false)) Increment("npcStatePacketsBroadcast"); + } + + private async Task RouteCombatHitAsync(ClientSession sender, JsonObject packet, CancellationToken cancellationToken) + { + JsonHelpers.TryUInt32(packet["targetPlayerId"], 1, uint.MaxValue, out var targetId); + if (targetId == sender.PlayerId) { Reject(sender, "Self-targeted combatHit", false); return; } + ClientSession? recipient; + lock (_gate) recipient = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == targetId); + if (recipient is null) { Reject(sender, $"Combat target {targetId} is not connected", false); return; } + if (!ProtocolValidation.StatesShareInterest(sender.TransformAnchor().Transform, recipient.TransformAnchor().Transform)) { Reject(sender, $"Combat target {targetId} is outside interest scope", true); return; } + if (await SendPacketAsync(recipient, packet, false, cancellationToken).ConfigureAwait(false)) Increment("combatHitsRouted"); + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + + private async Task ReconcileNpcAuthorityAsync(CancellationToken cancellationToken) + { + IReadOnlyList changes; + lock (_gate) + { + var players = _clients.Values.Where(x => x.GameplayActive && x.ProtocolVersion >= ProtocolConstants.ProtocolVersion) + .Select(x => (x.PlayerId, Scope: AuthorityScopeFromTransform(x.TransformAnchor().Transform))) + .Where(x => x.Scope is not null).Select(x => (x.PlayerId, x.Scope!.Value)); + changes = _npcAuthority.Reconcile(players); + foreach (var change in changes) _lastNpcStateByScope.Remove(change.Scope); + } + foreach (var change in changes) + { + var packet = AuthorityPacket(change.PlayerId, change.Epoch, change.Scope); + await BroadcastAsync(packet, null, true, cancellationToken).ConfigureAwait(false); + Increment("npcAuthorityChanges"); + Log($"NPC authority scope {change.Scope.CellId}/{(string.IsNullOrEmpty(change.Scope.WorldspaceId) ? "" : change.Scope.WorldspaceId)}: {change.PreviousPlayerId} -> {change.PlayerId}, epoch {change.Epoch}"); + } + } + + private async Task SendNpcAuthorityForClientAsync(ClientSession client, ScopeKey scope, CancellationToken cancellationToken) + { + if (client.ProtocolVersion < ProtocolConstants.ProtocolVersion) return; + AuthorityAssignment? assignment; + lock (_gate) assignment = _npcAuthority.Get(scope); + if (assignment is null) return; + if (!await SendPacketAsync(client, AuthorityPacket(assignment.Value.PlayerId, assignment.Value.Epoch, scope), false, cancellationToken).ConfigureAwait(false)) await DisconnectClientAsync(client).ConfigureAwait(false); + } + + private static JsonObject AuthorityPacket(uint playerId, uint epoch, ScopeKey scope) => new() + { + ["type"] = "npcAuthority", ["authorityPlayerId"] = playerId, ["authorityEpoch"] = epoch, + ["authorityCellId"] = scope.CellId, ["authorityWorldspaceId"] = scope.WorldspaceId, ["serverTime"] = JsonHelpers.UnixTime() + }; + + private static ScopeKey? AuthorityScopeFromTransform(JsonObject? transform) + { + if (transform is null) return null; + var cell = JsonHelpers.String(transform["cellId"]); var world = JsonHelpers.String(transform["worldspaceId"]) ?? string.Empty; + return cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true) + ? null : new ScopeKey(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world)); + } + + private static ScopeKey? AuthorityScopeFromPacket(JsonObject packet) + { + var cell = JsonHelpers.String(packet["authorityCellId"]); var world = JsonHelpers.String(packet["authorityWorldspaceId"]) ?? string.Empty; + return cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true) + ? null : new ScopeKey(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world)); + } + + private async Task BroadcastAsync(JsonObject packet, ClientSession? exclude, bool v2Only, CancellationToken cancellationToken) + { + ClientSession[] recipients; + lock (_gate) recipients = _clients.Values.Where(x => x.GameplayActive && (exclude is null || x.Connection.ConnectionKey != exclude.Connection.ConnectionKey) && (!v2Only || x.ProtocolVersion >= ProtocolConstants.ProtocolVersion)).ToArray(); + var sent = 0; + foreach (var recipient in recipients) + { + if (await SendPacketAsync(recipient, packet, true, cancellationToken).ConfigureAwait(false)) sent++; + else await DisconnectClientAsync(recipient).ConfigureAwait(false); + } + return sent; + } + + private async Task BroadcastWorldStateHostAsync(uint playerId, CancellationToken cancellationToken) + { + var sent = await BroadcastAsync(new JsonObject { ["type"] = "worldStateHost", ["worldStateHostPlayerId"] = playerId, ["serverTime"] = JsonHelpers.UnixTime() }, null, false, cancellationToken).ConfigureAwait(false); + Add("worldStateHostPacketsBroadcast", sent); + } + + private async Task BroadcastDisconnectAsync(ClientSession client) + { + var sent = await BroadcastAsync(new JsonObject { ["type"] = "disconnect", ["playerId"] = client.PlayerId, ["serverTime"] = JsonHelpers.UnixTime() }, null, false, CancellationToken.None).ConfigureAwait(false); + Add("disconnectPacketsBroadcast", sent); + } + + private async Task SendPacketAsync(ClientSession client, JsonObject packet, bool broadcast, CancellationToken cancellationToken) + { + EncodedPacket encoded; + try { encoded = PacketCodec.Encode(packet); } + catch (PacketCodecException) { return false; } + var result = await client.Connection.SendAsync(encoded, cancellationToken).ConfigureAwait(false); + var success = result == SendOutcome.Sent || (encoded.Delivery == Delivery.UnreliableSequenced && result is SendOutcome.Dropped or SendOutcome.Backpressure); + if (!success) return false; + client.RecordSent(broadcast); + lock (_gate) { _stats["packetsSent"]++; if (broadcast) _stats["packetsBroadcast"]++; } + return true; + } + + private async Task SendDirectSessionEndedAsync(IGameConnection connection, string code, string reason, CancellationToken cancellationToken) + { + var packet = new JsonObject { ["type"] = "sessionEnded", ["code"] = code, ["reason"] = reason ?? string.Empty, ["serverTime"] = JsonHelpers.UnixTime() }; + try + { + var result = await connection.SendAsync(PacketCodec.Encode(packet), cancellationToken).ConfigureAwait(false); + if (result == SendOutcome.Sent) lock (_gate) { _stats["sessionEndedPacketsSent"]++; _stats["packetsSent"]++; } + } + catch { } + } + + private async Task EndSessionAsync(ClientSession client, string code, string reason) + { + var packet = new JsonObject { ["type"] = "sessionEnded", ["code"] = code, ["reason"] = reason ?? string.Empty, ["serverTime"] = JsonHelpers.UnixTime() }; + if (await SendPacketAsync(client, packet, false, CancellationToken.None).ConfigureAwait(false)) Increment("sessionEndedPacketsSent"); + await DisconnectClientAsync(client).ConfigureAwait(false); + } + + private async Task DisconnectClientAsync(ClientSession client) + { + bool removed; bool wasActive; bool wasHost; + lock (_gate) + { + wasActive = client.GameplayActive; wasHost = wasActive && _worldStateHostPlayerId == client.PlayerId; + removed = _clients.Remove(client.Connection.ConnectionKey); + if (removed) _lastPlayerStateByPlayerId.Remove(client.PlayerId); + if (removed && wasActive) _stats["clientsDisconnected"]++; + } + if (!removed) return; + await client.Connection.DisconnectAsync(0, "Commonwealth Online disconnect"); + if (!wasActive) { Log($"Closed pending/probe connection: {client.Label}", "debug"); return; } + Log($"Client disconnected: {client.Label} (player {client.PlayerId})"); + await BroadcastDisconnectAsync(client).ConfigureAwait(false); + await ReconcileNpcAuthorityAsync(CancellationToken.None).ConfigureAwait(false); + if (wasHost) await ReassignWorldStateHostAsync().ConfigureAwait(false); + } + + private async Task ReassignWorldStateHostAsync() + { + uint? newHost; + lock (_gate) + { + var active = _clients.Values.Where(x => x.GameplayActive).ToArray(); + if (active.Length == 0) { _worldStateHostPlayerId = null; _lastLegacyNpcState = null; return; } + newHost = active.Min(x => x.PlayerId); _worldStateHostPlayerId = newHost; _lastLegacyNpcState = null; + } + await BroadcastWorldStateHostAsync(newHost!.Value, CancellationToken.None).ConfigureAwait(false); + Log($"Reassigned world-state host to player {newHost.Value}."); + } + + private void Reject(ClientSession client, string reason, bool warning = true) + { + lock (_gate) RejectLocked(client, reason, warning); + } + + private void RejectLocked(ClientSession client, string reason, bool warning = true) + { + _stats["packetsRejected"]++; + Log($"Rejected packet from {client.Label} (player {client.PlayerId}): {reason}", warning ? "warning" : "debug"); + } + + private ClientSession? FindByConnection(IGameConnection connection) + { + lock (_gate) return _clients.TryGetValue(connection.ConnectionKey, out var client) ? client : null; + } + + private void Increment(string name) { lock (_gate) _stats[name]++; } + private void Add(string name, long amount) { lock (_gate) _stats[name] += amount; } + + private async Task MaintenanceLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try { await Task.Delay(500, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } + ClientSession[] snapshot; + lock (_gate) snapshot = _clients.Values.ToArray(); + var now = JsonHelpers.UnixTime(); + foreach (var client in snapshot) + { + if (!client.GameplayActive && now - client.ConnectedAt > ClientHandshakeTimeoutSeconds) + { + Log($"Handshake timeout: {client.Label}", "warning"); + await DisconnectClientAsync(client).ConfigureAwait(false); + } + else if (client.GameplayActive && client.LastPacketAt is { } last && now - last > ClientIdleTimeoutSeconds) + { + Log($"Idle timeout: player {client.PlayerId}", "warning"); + await DisconnectClientAsync(client).ConfigureAwait(false); + } + } + } + } + + public JsonObject GetCoreStats() + { + lock (_gate) + { + var active = _clients.Values.Count(x => x.GameplayActive); + var stats = new JsonObject(); + foreach (var pair in _stats) stats[pair.Key] = pair.Value; + stats["host"] = _options.Host; stats["port"] = _options.Port; stats["serverName"] = _options.ServerName; + stats["serverDescription"] = _options.ServerDescription; stats["maxPlayers"] = _options.MaxPlayers; stats["isRunning"] = true; + stats["startedAt"] = _startedAt; stats["uptimeSeconds"] = JsonHelpers.UnixTime() - _startedAt; stats["connectedClients"] = active; + stats["pendingConnections"] = _clients.Count - active; stats["nextPlayerId"] = _nextPlayerId; stats["protocolVersion"] = ProtocolConstants.ProtocolVersion; + stats["npcAuthorityScopes"] = _npcAuthority.Assignments.Count; + return stats; + } + } + + public JsonObject GetAdminStats() + { + var core = GetCoreStats(); + return new JsonObject + { + ["is_running"] = true, ["host"] = _options.Host, ["port"] = _options.Port.ToString(), ["server_name"] = _options.ServerName, + ["server_description"] = _options.ServerDescription, ["uptime_seconds"] = core["uptimeSeconds"]?.DeepClone(), + ["connected_clients"] = core["connectedClients"]?.DeepClone(), ["packets_received"] = core["packetsReceived"]?.DeepClone(), + ["packets_sent"] = core["packetsSent"]?.DeepClone(), ["transform_packets_received"] = core["transformPacketsReceived"]?.DeepClone(), + ["transform_packets_broadcast"] = core["transformPacketsBroadcast"]?.DeepClone(), ["world_state_packets_received"] = core["worldStatePacketsReceived"]?.DeepClone(), + ["world_state_packets_broadcast"] = core["worldStatePacketsBroadcast"]?.DeepClone() + }; + } + + public JsonArray GetClientSnapshots() + { + lock (_gate) return new JsonArray(_clients.Values.Where(x => x.GameplayActive).Select(x => (JsonNode)x.Snapshot()).ToArray()); + } + + public JsonArray GetAdminClients() + { + lock (_gate) + { + return new JsonArray(_clients.Values.Where(x => x.GameplayActive).Select(client => + { + var snapshot = client.Snapshot(); + var endpoint = client.Label; + return (JsonNode)new JsonObject + { + ["player_id"] = client.PlayerId, ["address"] = endpoint, ["label"] = endpoint, ["connected_at"] = client.ConnectedAt, + ["packets_sent"] = client.PacketsSent, ["packets_received"] = client.PacketsReceived, ["last_transform"] = snapshot["lastTransform"]?.DeepClone() + }; + }).ToArray()); + } + } + + public IReadOnlyList ListBans() => _banStore.List(); + + public async Task<(bool Ok, string Message, JsonObject? Data)> KickAsync(uint playerId, string reason) + { + ClientSession? client; lock (_gate) client = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == playerId); + if (client is null) return (false, $"No connected player with id {playerId}", null); + var data = new JsonObject { ["playerId"] = playerId, ["ip"] = client.RemoteEndpoint.Address.ToString(), ["code"] = "kicked", ["reason"] = reason ?? string.Empty }; + await EndSessionAsync(client, "kicked", reason ?? string.Empty).ConfigureAwait(false); + return (true, $"Kicked player {playerId}.", data); + } + + public async Task<(bool Ok, string Message, JsonObject? Data)> BanPlayerAsync(uint playerId, string reason) + { + ClientSession? client; lock (_gate) client = _clients.Values.FirstOrDefault(x => x.GameplayActive && x.PlayerId == playerId); + return client is null ? (false, $"No connected player with id {playerId}", null) : await BanIpAsync(client.RemoteEndpoint.Address.ToString(), reason).ConfigureAwait(false); + } + + public async Task<(bool Ok, string Message, JsonObject? Data)> BanIpAsync(string ip, string reason) + { + BanEntry entry; + try { entry = _banStore.Ban(ip, reason ?? string.Empty); } catch (ArgumentException ex) { return (false, ex.Message, null); } + ClientSession[] sessions; lock (_gate) sessions = _clients.Values.Where(x => x.RemoteEndpoint.Address.ToString() == entry.Ip).ToArray(); + foreach (var session in sessions) await EndSessionAsync(session, "banned", entry.Reason).ConfigureAwait(false); + var data = new JsonObject { ["ip"] = entry.Ip, ["reason"] = entry.Reason, ["bannedAt"] = entry.BannedAt, ["sessionsEnded"] = sessions.Length }; + Log($"Banned IP {entry.Ip}{(string.IsNullOrEmpty(entry.Reason) ? string.Empty : $" (reason: {entry.Reason})")}"); + return (true, $"Banned IP {entry.Ip}.", data); + } + + public (bool Ok, string Message) Unban(string ip) + { + var removed = _banStore.Unban(ip); + if (removed) Log($"Unbanned IP {ip}"); + return removed ? (true, $"Unbanned IP {ip}.") : (false, $"IP {ip} is not banned."); + } + + public async Task SetServerTimeAsync(string hhmm) + { + if (WorldStatePresets.HhmmToGameHour(hhmm) is null) return false; + var normalized = hhmm.Trim().PadLeft(4, '0'); + lock (_gate) _serverWorldState["timeHHmm"] = normalized; + await BroadcastServerWorldStateAsync().ConfigureAwait(false); + return true; + } + + public async Task SetServerWeatherAsync(string value) + { + string normalized; + try { normalized = WorldStatePresets.NormalizeWeatherConsoleArg(value); } catch { return false; } + lock (_gate) { _serverWorldState["weatherConsoleArg"] = normalized; _serverWorldState["weatherFormId"] = WorldStatePresets.RelayWeatherFormId(normalized); } + await BroadcastServerWorldStateAsync().ConfigureAwait(false); + return true; + } + + private async Task BroadcastServerWorldStateAsync() + { + Dictionary snapshot; lock (_gate) snapshot = new Dictionary(_serverWorldState, StringComparer.Ordinal); + var packets = new List(); + if (snapshot.TryGetValue("timeHHmm", out var time)) packets.Add(new JsonObject { ["type"] = "serverWorldState", ["timeHHmm"] = time, ["serverTime"] = JsonHelpers.UnixTime() }); + if (snapshot.TryGetValue("weatherConsoleArg", out var weather)) + { + var packet = new JsonObject { ["type"] = "serverWorldState", ["weatherConsoleArg"] = weather, ["serverTime"] = JsonHelpers.UnixTime() }; + if (snapshot.TryGetValue("weatherFormId", out var form)) packet["weatherFormId"] = form; + packets.Add(packet); + } + var sent = 0; + foreach (var packet in packets) sent += await BroadcastAsync(packet, null, false, CancellationToken.None).ConfigureAwait(false); + Add("serverWorldStatePacketsBroadcast", sent); + } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + try { await _maintenanceTask.ConfigureAwait(false); } catch { } + ClientSession[] clients; lock (_gate) { clients = _clients.Values.ToArray(); _clients.Clear(); } + foreach (var client in clients) await client.Connection.DisposeAsync(); + _shutdown.Dispose(); + } +} diff --git a/server/CommonwealthOnline.Server.csproj b/server/CommonwealthOnline.Server.csproj new file mode 100644 index 0000000..f1104f1 --- /dev/null +++ b/server/CommonwealthOnline.Server.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0 + enable + enable + latest + true + CommonwealthOnline.Server + CommonwealthOnline.Server + + + + + diff --git a/server/Configuration.cs b/server/Configuration.cs new file mode 100644 index 0000000..ebdab92 --- /dev/null +++ b/server/Configuration.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal sealed class ServerOptions +{ + public string Host { get; set; } = "0.0.0.0"; + public int Port { get; set; } = 7777; + public string ServerName { get; set; } = "Commonwealth Online Server"; + public string ServerDescription { get; set; } = string.Empty; + public int MaxPlayers { get; set; } = 16; + public string LogVerbosity { get; set; } = "info"; + public int AdminPort { get; set; } = 7779; + public bool EnableGnsTransport { get; set; } + public string? GnsBridgePath { get; set; } + public string ConfigPath { get; set; } = Path.GetFullPath("commonwealth-server.json"); + + public string BaseDirectory => Path.GetDirectoryName(ConfigPath)!; + public string BansPath => Path.Combine(BaseDirectory, "bans.json"); + public string AdminTokenPath => Path.Combine(BaseDirectory, ".admin-token"); + + public static ServerOptions Load(string path) + { + var fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) throw new FileNotFoundException("Config file not found", fullPath); + var root = JsonNode.Parse(File.ReadAllBytes(fullPath)) as JsonObject ?? throw new InvalidDataException("Config file must contain a JSON object at root level."); + var options = new ServerOptions { ConfigPath = fullPath }; + options.Host = JsonHelpers.String(root["host"]) ?? options.Host; + options.Port = ReadInt(root["port"], options.Port, "port"); + options.ServerName = JsonHelpers.String(root["server_name"] ?? root["serverName"]) ?? options.ServerName; + options.ServerDescription = JsonHelpers.String(root["server_description"] ?? root["serverDescription"]) ?? options.ServerDescription; + options.MaxPlayers = ReadInt(root["max_players"] ?? root["maxPlayers"], options.MaxPlayers, "max_players"); + options.LogVerbosity = (JsonHelpers.String(root["log_verbosity"] ?? root["logVerbosity"]) ?? options.LogVerbosity).Trim().ToLowerInvariant(); + options.AdminPort = ReadInt(root["admin_port"] ?? root["adminPort"], options.AdminPort, "admin_port"); + options.EnableGnsTransport = ReadBool(root["enable_gns_transport"] ?? root["enableGnsTransport"], false, "enable_gns_transport"); + var bridge = JsonHelpers.String(root["gns_bridge_path"] ?? root["gnsBridgePath"]); + options.GnsBridgePath = string.IsNullOrWhiteSpace(bridge) ? null : bridge.Trim(); + return options; + } + + public static ServerOptions CreateDefault(string path) + { + var options = new ServerOptions { ConfigPath = Path.GetFullPath(path) }; + options.Save(); + return options; + } + + public void Save() + { + Directory.CreateDirectory(BaseDirectory); + var root = new JsonObject + { + ["host"] = Host, + ["port"] = Port, + ["server_name"] = ServerName, + ["server_description"] = ServerDescription, + ["max_players"] = MaxPlayers, + ["log_verbosity"] = LogVerbosity, + ["admin_port"] = AdminPort, + ["enable_gns_transport"] = EnableGnsTransport, + ["gns_bridge_path"] = GnsBridgePath + }; + using var stream = File.Create(ConfigPath); + JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true }); + stream.WriteByte((byte)'\n'); + } + + public IReadOnlyList Validate() + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(Host)) + errors.Add("host cannot be empty"); + else if (!TryResolveIpv4(Host, out _)) + errors.Add($"host '{Host}' is not a valid IPv4 address or resolvable hostname"); + else if (EnableGnsTransport && (!IPAddress.TryParse(Host.Trim(), out var gnsAddress) || gnsAddress.AddressFamily != AddressFamily.InterNetwork)) + errors.Add("enable_gns_transport requires host to be an explicit IPv4 bind address such as 0.0.0.0 or 127.0.0.1"); + + if (Port is < 1 or > 65535) errors.Add($"port must be 1-65535, got {Port}"); + if (AdminPort is < 1 or > 65535) errors.Add($"admin_port must be 1-65535, got {AdminPort}"); + else if (AdminPort == Port) errors.Add("admin_port must differ from the game port"); + if (string.IsNullOrEmpty(ServerName)) errors.Add("server_name cannot be empty"); + else if (ServerName.Length > 64) errors.Add($"server_name must be <= 64 characters, got {ServerName.Length}"); + if (ServerDescription.Length > 256) errors.Add($"server_description must be <= 256 characters, got {ServerDescription.Length}"); + if (MaxPlayers < 1) errors.Add($"max_players must be >= 1, got {MaxPlayers}"); + else if (MaxPlayers > 256) errors.Add($"max_players must be <= 256, got {MaxPlayers}"); + if (LogVerbosity is not ("debug" or "info" or "warning" or "error")) errors.Add($"log_verbosity must be debug/info/warning/error, got {LogVerbosity}"); + return errors; + } + + public static bool TryResolveIpv4(string host, out IPAddress address) + { + if (IPAddress.TryParse(host.Trim(), out var parsed) && parsed.AddressFamily == AddressFamily.InterNetwork) + { + address = parsed; + return true; + } + try + { + address = Dns.GetHostAddresses(host.Trim()).First(x => x.AddressFamily == AddressFamily.InterNetwork); + return true; + } + catch + { + address = IPAddress.None; + return false; + } + } + + private static int ReadInt(JsonNode? node, int defaultValue, string field) + { + if (node is null) return defaultValue; + if (node is JsonValue value && value.TryGetValue(out var number)) return number; + if (node is JsonValue textValue && textValue.TryGetValue(out var text) && int.TryParse(text, out number)) return number; + throw new InvalidDataException($"Invalid numeric config field: {field}"); + } + + private static bool ReadBool(JsonNode? node, bool defaultValue, string field) + { + if (node is null) return defaultValue; + if (node is JsonValue value && value.TryGetValue(out var boolean)) return boolean; + if (node is JsonValue intValue && intValue.TryGetValue(out var number) && number is 0 or 1) return number == 1; + if (node is JsonValue textValue && textValue.TryGetValue(out var text)) + { + switch (text.Trim().ToLowerInvariant()) + { + case "1": case "true": case "yes": case "on": return true; + case "0": case "false": case "no": case "off": case "": return false; + } + } + throw new InvalidDataException($"{field} must be a boolean or one of true/false, yes/no, on/off, 1/0"); + } +} diff --git a/server/Domain.cs b/server/Domain.cs new file mode 100644 index 0000000..afed98c --- /dev/null +++ b/server/Domain.cs @@ -0,0 +1,317 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal readonly record struct ScopeKey(string CellId, string WorldspaceId) : IComparable +{ + public int CompareTo(ScopeKey other) + { + var cell = string.Compare(CellId, other.CellId, StringComparison.Ordinal); + return cell != 0 ? cell : string.Compare(WorldspaceId, other.WorldspaceId, StringComparison.Ordinal); + } +} + +internal readonly record struct StateScope(string CellId, string WorldspaceId, double X, double Y); +internal readonly record struct AuthorityAssignment(ScopeKey Scope, uint PlayerId, uint Epoch); +internal readonly record struct AuthorityChange(ScopeKey Scope, uint PreviousPlayerId, uint PlayerId, uint Epoch); + +internal sealed class NpcAuthorityManager +{ + private readonly Dictionary _assignments = new(); + private readonly Dictionary _lastEpoch = new(); + + public void Clear() + { + _assignments.Clear(); + _lastEpoch.Clear(); + } + + public AuthorityAssignment? Get(ScopeKey scope) => _assignments.TryGetValue(scope, out var value) ? value : null; + public IReadOnlyCollection Assignments => _assignments.OrderBy(x => x.Key).Select(x => x.Value).ToArray(); + + public bool Authorize(uint playerId, ScopeKey scope, uint epoch) => + _assignments.TryGetValue(scope, out var assignment) && assignment.PlayerId == playerId && assignment.Epoch == epoch; + + public IReadOnlyList Reconcile(IEnumerable<(uint PlayerId, ScopeKey Scope)> players) + { + var desired = players + .Where(x => x.PlayerId > 0) + .GroupBy(x => x.Scope) + .ToDictionary(group => group.Key, group => group.Min(x => x.PlayerId)); + + var scopes = _assignments.Keys.Concat(desired.Keys).Distinct().OrderBy(x => x).ToArray(); + var changes = new List(); + foreach (var scope in scopes) + { + var previous = _assignments.TryGetValue(scope, out var oldAssignment) ? oldAssignment.PlayerId : 0; + var next = desired.TryGetValue(scope, out var nextPlayer) ? nextPlayer : 0; + if (previous == next) continue; + var epoch = _lastEpoch.TryGetValue(scope, out var last) ? checked(last + 1) : 1; + _lastEpoch[scope] = epoch; + if (next == 0) _assignments.Remove(scope); + else _assignments[scope] = new AuthorityAssignment(scope, next, epoch); + changes.Add(new AuthorityChange(scope, previous, next, epoch)); + } + return changes; + } +} + +internal sealed class ClientSession +{ + private readonly object _gate = new(); + + public ClientSession(IGameConnection connection, uint playerId) + { + Connection = connection; + PlayerId = playerId; + ConnectedAt = JsonHelpers.UnixTime(); + RateWindowStart = MonotonicClock.Now; + } + + public IGameConnection Connection { get; } + public uint PlayerId { get; } + public IPEndPoint RemoteEndpoint => Connection.RemoteEndpoint; + public string Label => $"{RemoteEndpoint.Address}:{RemoteEndpoint.Port}"; + public double ConnectedAt { get; } + public double? LastPacketAt { get; private set; } + public JsonObject? LastTransform { get; private set; } + public double? LastTransformMonotonic { get; private set; } + public long PacketsReceived { get; private set; } + public long PacketsSent { get; private set; } + public long PacketsBroadcast { get; private set; } + public bool GameplayActive { get; private set; } + public int ProtocolVersion { get; private set; } + public double RateWindowStart { get; set; } + public int RateWindowCount { get; set; } + public int RateViolations { get; set; } + public bool RateWindowBlocked { get; set; } + public uint LastCombatSequence { get; set; } + + public void RecordReceived() + { + lock (_gate) + { + LastPacketAt = JsonHelpers.UnixTime(); + PacketsReceived++; + } + } + + public void RecordTransform(JsonObject packet, double acceptedMonotonic) + { + lock (_gate) + { + LastTransform = JsonHelpers.CloneObject(packet); + LastTransformMonotonic = acceptedMonotonic; + } + } + + public (JsonObject? Transform, double? Monotonic) TransformAnchor() + { + lock (_gate) + return (LastTransform is null ? null : JsonHelpers.CloneObject(LastTransform), LastTransformMonotonic); + } + + public void RecordSent(bool broadcast) + { + lock (_gate) + { + PacketsSent++; + if (broadcast) PacketsBroadcast++; + } + } + + public bool Activate(int protocolVersion) + { + lock (_gate) + { + if (GameplayActive) return false; + GameplayActive = true; + ProtocolVersion = protocolVersion; + return true; + } + } + + public JsonObject Snapshot() + { + lock (_gate) + { + return new JsonObject + { + ["playerId"] = PlayerId, + ["address"] = RemoteEndpoint.Address.ToString(), + ["port"] = RemoteEndpoint.Port, + ["connectedAt"] = ConnectedAt, + ["lastPacketAt"] = LastPacketAt, + ["lastTransform"] = LastTransform?.DeepClone(), + ["packetsReceived"] = PacketsReceived, + ["packetsSent"] = PacketsSent, + ["packetsBroadcast"] = PacketsBroadcast, + ["gameplayActive"] = GameplayActive, + ["protocolVersion"] = ProtocolVersion + }; + } + } +} + +internal readonly record struct BanEntry(string Ip, string Reason, double BannedAt); + +internal sealed class BanStore +{ + private readonly object _gate = new(); + private readonly string _path; + private Dictionary _bans = new(StringComparer.Ordinal); + + public BanStore(string path) + { + _path = Path.GetFullPath(path); + Load(); + } + + public BanEntry? GetBan(string ip) + { + if (!TryNormalizeIp(ip, out var normalized)) return null; + lock (_gate) return _bans.TryGetValue(normalized, out var entry) ? entry : null; + } + + public IReadOnlyList List() + { + lock (_gate) return _bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).ToArray(); + } + + public BanEntry Ban(string ip, string reason) + { + if (!TryNormalizeIp(ip, out var normalized)) throw new ArgumentException($"Invalid IP address: '{ip}'", nameof(ip)); + var entry = new BanEntry(normalized, (reason ?? string.Empty)[..Math.Min((reason ?? string.Empty).Length, 1024)], JsonHelpers.UnixTime()); + lock (_gate) + { + _bans[normalized] = entry; + SaveLocked(); + } + return entry; + } + + public bool Unban(string ip) + { + if (!TryNormalizeIp(ip, out var normalized)) return false; + lock (_gate) + { + if (!_bans.Remove(normalized)) return false; + SaveLocked(); + return true; + } + } + + private void Load() + { + lock (_gate) + { + if (!File.Exists(_path)) { _bans = new(StringComparer.Ordinal); return; } + JsonObject root; + try + { + root = JsonNode.Parse(File.ReadAllBytes(_path)) as JsonObject ?? throw new InvalidDataException("root is not an object"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + throw new InvalidOperationException($"Could not load ban file {_path}: {ex.Message}", ex); + } + if (root["banned_ips"] is not JsonArray array) throw new InvalidOperationException($"Ban file {_path} has an invalid schema."); + var bans = new Dictionary(StringComparer.Ordinal); + var invalid = 0; + foreach (var node in array) + { + if (node is not JsonObject item || !TryNormalizeIp(JsonHelpers.String(item["ip"]) ?? string.Empty, out var ip)) { invalid++; continue; } + var reason = JsonHelpers.String(item["reason"]) ?? string.Empty; + if (reason.Length > 1024) reason = reason[..1024]; + var bannedAt = JsonHelpers.TryDouble(item["bannedAt"] ?? item["banned_at"], double.MinValue, double.MaxValue, out var value) ? value : 0.0; + bans[ip] = new BanEntry(ip, reason, bannedAt); + } + if (invalid > 0) throw new InvalidOperationException($"Ban file {_path} contains {invalid} invalid {(invalid == 1 ? "entry" : "entries")}."); + _bans = bans; + } + } + + private void SaveLocked() + { + var directory = Path.GetDirectoryName(_path)!; + Directory.CreateDirectory(directory); + var root = new JsonObject + { + ["banned_ips"] = new JsonArray(_bans.Values.OrderBy(x => x.Ip, StringComparer.Ordinal).Select(entry => (JsonNode)new JsonObject + { + ["ip"] = entry.Ip, + ["reason"] = entry.Reason, + ["bannedAt"] = entry.BannedAt + }).ToArray()) + }; + var temp = Path.Combine(directory, $".{Path.GetFileName(_path)}.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, root, new JsonSerializerOptions { WriteIndented = true }); + stream.WriteByte((byte)'\n'); + stream.Flush(true); + } + TryRestrictPermissions(temp); + File.Move(temp, _path, true); + TryRestrictPermissions(_path); + } + + private static bool TryNormalizeIp(string value, out string normalized) + { + normalized = string.Empty; + if (!IPAddress.TryParse(value.Trim(), out var address)) return false; + normalized = address.ToString(); + return true; + } + + internal static void TryRestrictPermissions(string path) + { + if (OperatingSystem.IsWindows()) return; + try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { } + } +} + +internal static class MonotonicClock +{ + private static readonly double Frequency = System.Diagnostics.Stopwatch.Frequency; + public static double Now => System.Diagnostics.Stopwatch.GetTimestamp() / Frequency; +} + +internal static class WorldStatePresets +{ + public static readonly IReadOnlyList<(string Label, string Value)> Weather = new (string, string)[] + { + ("Clear", "0002b52a"), ("Cloudy", "001cc186"), ("Overcast", "001c8556"), ("Fog", "001c3473"), + ("Rain", "001ca7e4"), ("Radstorm", "001c3d5e"), ("Glowing Sea", "000f1033") + }; + + public static readonly IReadOnlyList<(string Label, string Value)> Time = new (string, string)[] + { + ("Midnight", "0000"), ("Dawn", "0600"), ("Morning", "0900"), ("Noon", "1200"), + ("Afternoon", "1500"), ("Evening", "1800"), ("Dusk (7 PM)", "1900"), ("Night", "2200") + }; + + public static string NormalizeWeatherConsoleArg(string value) + { + var text = value.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) text = text[2..]; + if (!uint.TryParse(text, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + throw new ArgumentException("invalid weather form id", nameof(value)); + return parsed.ToString("x8", System.Globalization.CultureInfo.InvariantCulture); + } + + public static string RelayWeatherFormId(string value) => uint.Parse(value, System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture).ToString("X8", System.Globalization.CultureInfo.InvariantCulture); + + public static double? HhmmToGameHour(string hhmm) + { + var text = hhmm.Trim(); + if (text.Length is < 1 or > 4 || !text.All(char.IsDigit)) return null; + text = text.PadLeft(4, '0'); + var hours = int.Parse(text[..2]); + var minutes = int.Parse(text[2..]); + return hours > 23 || minutes > 59 ? null : hours + minutes / 60.0; + } +} diff --git a/server/GnsTransport.cs b/server/GnsTransport.cs new file mode 100644 index 0000000..4ccf9e2 --- /dev/null +++ b/server/GnsTransport.cs @@ -0,0 +1,320 @@ +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal enum GnsEventType : uint { None = 0, Connected = 1, Disconnected = 2, Message = 3, OversizeMessage = 4 } + +internal sealed unsafe class GnsNativeServer : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct NativeEvent + { + public uint Type; + public uint ConnectionId; + public int Reason; + public uint PayloadSize; + public fixed byte Debug[128]; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int CreateDelegate([MarshalAs(UnmanagedType.LPUTF8Str)] string bindHost, ushort port, out IntPtr handle, IntPtr errorBuffer, nuint errorBufferSize); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void DestroyDelegate(IntPtr handle); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate ushort LocalPortDelegate(IntPtr handle); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate uint ConnectionCountDelegate(IntPtr handle); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int PollDelegate(IntPtr handle, NativeEvent* outEvent, IntPtr payloadBuffer, uint payloadCapacity); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int SendDelegate(IntPtr handle, uint connectionId, IntPtr payload, uint payloadSize, uint delivery); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int DisconnectDelegate(IntPtr handle, uint connectionId, int reason, [MarshalAs(UnmanagedType.LPUTF8Str)] string debug); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int RemoteIpv4Delegate(IntPtr handle, uint connectionId, out uint ipv4HostOrder, out ushort port); + + private readonly IntPtr _library; + private IntPtr _handle; + private readonly DestroyDelegate _destroy; + private readonly LocalPortDelegate _localPort; + private readonly ConnectionCountDelegate _connectionCount; + private readonly PollDelegate _poll; + private readonly SendDelegate _send; + private readonly DisconnectDelegate _disconnect; + private readonly RemoteIpv4Delegate _remoteIpv4; + private readonly IntPtr _payloadBuffer = Marshal.AllocHGlobal(ProtocolConstants.MaxMessageBytes); + private int _disposed; + + public GnsNativeServer(string bindHost, int port, string? configuredPath, string serverBaseDirectory) + { + var libraryPath = ResolveLibrary(configuredPath, serverBaseDirectory); + _library = NativeLibrary.Load(libraryPath); + var create = Get("co_gns_server_create"); + _destroy = Get("co_gns_server_destroy"); + _localPort = Get("co_gns_server_local_port"); + _connectionCount = Get("co_gns_server_connection_count"); + _poll = Get("co_gns_server_poll"); + _send = Get("co_gns_server_send"); + _disconnect = Get("co_gns_server_disconnect"); + _remoteIpv4 = Get("co_gns_server_remote_ipv4"); + + var errorBuffer = Marshal.AllocHGlobal(512); + try + { + new Span((void*)errorBuffer, 512).Clear(); + var result = create(bindHost, checked((ushort)port), out _handle, errorBuffer, 512); + if (result != 1 || _handle == IntPtr.Zero) + throw new InvalidOperationException(Marshal.PtrToStringUTF8(errorBuffer) ?? "GNS native bridge failed to start"); + } + finally { Marshal.FreeHGlobal(errorBuffer); } + } + + public ushort LocalPort => _localPort(_handle); + public uint ConnectionCount => _connectionCount(_handle); + + public (GnsEventType Type, uint ConnectionId, int Reason, byte[] Payload, string Debug)? Poll() + { + NativeEvent native = default; + var result = _poll(_handle, &native, _payloadBuffer, ProtocolConstants.MaxMessageBytes); + if (result == 0) return null; + if (result < 0) throw new IOException($"GNS native poll failed with result {result}"); + if (!Enum.IsDefined(typeof(GnsEventType), native.Type)) throw new IOException($"GNS native bridge returned unknown event type {native.Type}"); + var type = (GnsEventType)native.Type; + if (native.PayloadSize > ProtocolConstants.MaxMessageBytes && type != GnsEventType.OversizeMessage) throw new IOException("GNS native bridge returned an oversized message payload"); + var payload = Array.Empty(); + if (type == GnsEventType.Message && native.PayloadSize > 0) + { + payload = new byte[native.PayloadSize]; + Marshal.Copy(_payloadBuffer, payload, 0, payload.Length); + } + string debug; + { + byte* pointer = native.Debug; + var length = 0; + while (length < 128 && pointer[length] != 0) length++; + debug = Encoding.UTF8.GetString(pointer, length); + } + return (type, native.ConnectionId, native.Reason, payload, debug); + } + + public SendOutcome Send(uint connectionId, ReadOnlySpan payload, Delivery delivery) + { + if (payload.Length > ProtocolConstants.MaxMessageBytes) return SendOutcome.TooLarge; + fixed (byte* pointer = payload) + { + return _send(_handle, connectionId, (IntPtr)pointer, (uint)payload.Length, (uint)delivery) switch + { + 0 => SendOutcome.Sent, + 1 => SendOutcome.Dropped, + 2 => SendOutcome.Backpressure, + 3 => SendOutcome.NotConnected, + 4 => SendOutcome.TooLarge, + _ => SendOutcome.Error + }; + } + } + + public void Disconnect(uint connectionId, int reason, string debug) => _disconnect(_handle, connectionId, reason, debug); + + public IPEndPoint? RemoteEndpoint(uint connectionId) + { + if (_remoteIpv4(_handle, connectionId, out var ipv4, out var port) != 1) return null; + var bytes = new[] { (byte)(ipv4 >> 24), (byte)(ipv4 >> 16), (byte)(ipv4 >> 8), (byte)ipv4 }; + return new IPEndPoint(new IPAddress(bytes), port); + } + + private T Get(string name) where T : Delegate => Marshal.GetDelegateForFunctionPointer(NativeLibrary.GetExport(_library, name)); + + private static string ResolveLibrary(string? configuredPath, string serverBaseDirectory) + { + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + var full = Path.GetFullPath(configuredPath, serverBaseDirectory); + if (File.Exists(full)) return full; + throw new FileNotFoundException("Configured GNS bridge was not found", full); + } + var name = OperatingSystem.IsWindows() ? "commonwealth_online_gns_bridge.dll" : OperatingSystem.IsMacOS() ? "libcommonwealth_online_gns_bridge.dylib" : "libcommonwealth_online_gns_bridge.so"; + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, name), + Path.Combine(AppContext.BaseDirectory, "native_transport", name), + Path.Combine(serverBaseDirectory, name), + Path.Combine(serverBaseDirectory, "native_transport", name), + Path.Combine(Environment.CurrentDirectory, "native_transport", name) + }; + return candidates.FirstOrDefault(File.Exists) ?? throw new FileNotFoundException($"Commonwealth Online GNS native bridge was not found. Searched: {string.Join(", ", candidates)}"); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + if (_handle != IntPtr.Zero) { _destroy(_handle); _handle = IntPtr.Zero; } + Marshal.FreeHGlobal(_payloadBuffer); + if (_library != IntPtr.Zero) NativeLibrary.Free(_library); + } +} + +internal sealed class GnsGameConnection : IGameConnection +{ + private readonly GnsNativeServer _native; + private readonly uint _id; + private readonly object _gate = new(); + private readonly Dictionary _outgoingSequences = new(StringComparer.Ordinal) + { + ["transform"] = new SequenceCounter(), ["npcState"] = new SequenceCounter() + }; + private int _closed; + + public GnsGameConnection(GnsNativeServer native, uint id, IPEndPoint remoteEndpoint) { _native = native; _id = id; RemoteEndpoint = remoteEndpoint; ConnectionKey = $"gns:{id}"; } + public string ConnectionKey { get; } + public string TransportName => "gns"; + public IPEndPoint RemoteEndpoint { get; } + public bool IsClosed => Volatile.Read(ref _closed) != 0; + public uint NativeId => _id; + + public ValueTask SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default) + { + if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected); + lock (_gate) + { + if (IsClosed) return ValueTask.FromResult(SendOutcome.NotConnected); + ReadOnlySpan wire = packet.Payload; + byte[]? envelope = null; + if (TransportPolicy.IsSnapshot(packet.PacketType)) + { + envelope = GnsSnapshotEnvelope.Encode(packet.PacketType, packet.Payload, _outgoingSequences[packet.PacketType].Advance()); + wire = envelope; + } + return ValueTask.FromResult(_native.Send(_id, wire, packet.Delivery)); + } + } + + public ValueTask DisconnectAsync(int reason, string debug) + { + if (Interlocked.Exchange(ref _closed, 1) == 0) { try { _native.Disconnect(_id, reason, debug); } catch { } } + return ValueTask.CompletedTask; + } + internal void MarkRemoteClosed() => Interlocked.Exchange(ref _closed, 1); + public ValueTask DisposeAsync() => DisconnectAsync(0, "dispose"); +} + +internal sealed class GnsServerTransport : IAsyncDisposable +{ + private readonly ServerOptions _options; + private readonly IServerIngress _server; + private readonly CancellationTokenSource _shutdown = new(); + private readonly Dictionary _connections = new(); + private readonly Dictionary<(uint ConnectionId, string PacketType), SequenceWindow> _incomingSequences = new(); + private readonly object _gate = new(); + private GnsNativeServer? _native; + private Task? _pumpTask; + + public GnsServerTransport(ServerOptions options, IServerIngress server) { _options = options; _server = server; } + + public void Start() + { + if (_pumpTask is not null) return; + _native = new GnsNativeServer(_options.Host, _options.Port, _options.GnsBridgePath, _options.BaseDirectory); + if (_native.LocalPort != _options.Port) throw new InvalidOperationException($"GNS transport bound UDP {_native.LocalPort}, expected UDP {_options.Port}."); + _pumpTask = Task.Run(() => PumpAsync(_shutdown.Token)); + _server.Log($"GameNetworkingSockets gameplay transport listening on UDP {_options.Port}."); + } + + private async Task PumpAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + (GnsEventType Type, uint ConnectionId, int Reason, byte[] Payload, string Debug)? evt; + try { evt = _native!.Poll(); } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) _server.Log($"GNS poll error: {ex.Message}", "error"); + try { await Task.Delay(10, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } + continue; + } + if (evt is null) + { + try { await Task.Delay(2, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } + continue; + } + var value = evt.Value; + switch (value.Type) + { + case GnsEventType.Connected: await HandleConnectedAsync(value.ConnectionId, cancellationToken).ConfigureAwait(false); break; + case GnsEventType.Message: await HandleMessageAsync(value.ConnectionId, value.Payload, cancellationToken).ConfigureAwait(false); break; + case GnsEventType.OversizeMessage: + if (TryGetConnection(value.ConnectionId, out var oversized)) await _server.EndSessionForTransportAsync(oversized, "packet_too_large", "Packet exceeded maximum message size.").ConfigureAwait(false); + else _native!.Disconnect(value.ConnectionId, 0, "Oversized pre-session packet"); + break; + case GnsEventType.Disconnected: await HandleDisconnectedAsync(value.ConnectionId).ConfigureAwait(false); break; + } + } + } + + private async Task HandleConnectedAsync(uint id, CancellationToken cancellationToken) + { + var endpoint = _native!.RemoteEndpoint(id); + if (endpoint is null) { _native.Disconnect(id, 0, "Remote endpoint unavailable"); return; } + var connection = new GnsGameConnection(_native, id, endpoint); + lock (_gate) _connections[id] = connection; + bool accepted; + try { accepted = await _server.AcceptConnectionAsync(connection, cancellationToken).ConfigureAwait(false); } + catch (Exception ex) { _server.Log($"GNS admission failed for {endpoint}: {ex.Message}", "warning"); accepted = false; } + if (!accepted) { lock (_gate) _connections.Remove(id); await connection.DisposeAsync(); } + } + + private async Task HandleMessageAsync(uint id, byte[] payload, CancellationToken cancellationToken) + { + if (!TryGetConnection(id, out var connection)) { _native!.Disconnect(id, 0, "Message before GNS admission"); return; } + ReadOnlyMemory gameplayPayload = payload; + if (GnsSnapshotEnvelope.TryDecode(payload, out var envelope, out var envelopeError)) + { + if (envelopeError is not null) { await _server.HandleTransportRejectAsync(connection, $"Malformed GNS snapshot envelope: {envelopeError}").ConfigureAwait(false); return; } + bool accepted; + lock (_gate) + { + if (!_incomingSequences.TryGetValue((id, envelope.PacketType), out var window)) _incomingSequences[(id, envelope.PacketType)] = window = new SequenceWindow(); + accepted = window.Accept(envelope.Sequence); + } + if (!accepted) { await _server.HandleTransportRejectAsync(connection, "Stale or duplicate GNS snapshot sequence", false).ConfigureAwait(false); return; } + JsonObject packet; + try { packet = PacketCodec.Decode(envelope.Payload); } + catch (PacketCodecException ex) { await _server.HandleTransportRejectAsync(connection, $"Invalid GNS snapshot payload: {ex.Message}").ConfigureAwait(false); return; } + if (JsonHelpers.String(packet["type"]) != envelope.PacketType) { await _server.HandleTransportRejectAsync(connection, "GNS snapshot envelope family does not match packet type").ConfigureAwait(false); return; } + gameplayPayload = envelope.Payload; + } + else + { + try + { + var packet = PacketCodec.Decode(payload); + var type = JsonHelpers.String(packet["type"])!; + if (TransportPolicy.IsSnapshot(type)) { await _server.HandleTransportRejectAsync(connection, "GNS snapshot missing required sequence envelope").ConfigureAwait(false); return; } + } + catch (PacketCodecException) { } + } + await _server.HandleMessageAsync(connection, gameplayPayload, cancellationToken).ConfigureAwait(false); + } + + private async Task HandleDisconnectedAsync(uint id) + { + GnsGameConnection? connection; + lock (_gate) + { + _connections.Remove(id, out connection); + foreach (var key in _incomingSequences.Keys.Where(x => x.ConnectionId == id).ToArray()) _incomingSequences.Remove(key); + } + if (connection is null) return; + connection.MarkRemoteClosed(); + await _server.HandleConnectionClosedAsync(connection).ConfigureAwait(false); + } + + private bool TryGetConnection(uint id, out GnsGameConnection connection) { lock (_gate) return _connections.TryGetValue(id, out connection!); } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + if (_pumpTask is not null) { try { await _pumpTask.ConfigureAwait(false); } catch { } } + GnsGameConnection[] connections; + lock (_gate) { connections = _connections.Values.ToArray(); _connections.Clear(); _incomingSequences.Clear(); } + foreach (var connection in connections) await connection.DisposeAsync(); + _native?.Dispose(); + _native = null; + _shutdown.Dispose(); + } +} 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/PROTOCOL_V2.md b/server/PROTOCOL_V2.md new file mode 100644 index 0000000..288c4e4 --- /dev/null +++ b/server/PROTOCOL_V2.md @@ -0,0 +1,74 @@ +# Commonwealth Online Protocol V2 + +Protocol V2 is the transition layer between the current TCP JSON relay and the planned GameNetworkingSockets transport. The gameplay schema and validation rules are intended to survive the transport change. + +## Session states + +A TCP connection starts as **pending**. Pending connections receive a `welcome` packet but do not count against active player capacity and cannot own world/NPC authority. + +A V2 client activates with: + +```json +{"type":"hello","protocolVersion":2} +``` + +The server replies with `sessionReady`. During the client transition, legacy clients are temporarily activated by their first valid gameplay packet. This compatibility behavior is temporary and tracked by issue #3. + +## Framing and limits + +- Current transport: UTF-8 newline-delimited JSON over TCP +- Maximum line size: 64 KiB +- Handshake timeout: 10 seconds +- Active idle timeout: 60 seconds +- Packet rate: 120 packets/second per client, with sustained-window escalation +- Connection attempts: 8 per source IP per 10 seconds +- JSON `NaN`/`Infinity` values are rejected +- Outbound JSON is serialized with non-finite values disabled + +## Interest management + +Player transforms are no longer blindly broadcast to every connected player. + +Peers are relevant when: + +1. Their normalized `cellId` values match, or +2. They have the same non-empty `worldspaceId` and are within 8192 world units in the XY plane. + +If an older client has not provided enough scope data yet, the server falls back to relaying for compatibility rather than hiding peers. + +NPC snapshots are filtered per recipient using the same interest rule. + +## Authority + +Only **active** sessions can own world/NPC authority. A TCP probe or idle pending socket cannot become host. + +The current global host is transitional. Per-cell/worldspace authority with epochs is tracked by issue #5. + +## Transform validation + +Required transform fields: + +- `x`, `y`, `z`, `angleZ`: finite and bounded +- `cellId`: non-zero hex FormID +- `worldspaceId`: optional hex FormID + +Optional V2-ready animation fields are normalized when present: + +- `animationDirection` +- `aimPitch` +- `turnDelta` + +Client capture/application work for those fields is tracked by issue #4. + +## Combat + +`combatHit` remains a targeted reliable-style gameplay event on the current transport. The relay now rejects malformed/non-finite values, duplicate or out-of-order sender sequences, self-targeting, disconnected targets, and targets outside the sender's interest scope. + +Server-authoritative movement correction and stronger combat validation are tracked by issue #6. + +## Transport migration + +GameNetworkingSockets migration is tracked by issue #2. The intended split is: + +- Unreliable/sequenced: transforms and NPC snapshots +- Reliable: session/control, actions, equipment, combat, world state, and authority changes diff --git a/server/Program.cs b/server/Program.cs new file mode 100644 index 0000000..3403345 --- /dev/null +++ b/server/Program.cs @@ -0,0 +1,337 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal static class Program +{ + public static async Task Main(string[] args) + { + try + { + return await RunAsync(args).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ERROR] {ex.Message}"); + return 2; + } + } + + private static async Task RunAsync(string[] args) + { + if (args.Length == 0 || args[0] is "help" or "--help" or "-h") + { + PrintHelp(); + return 0; + } + + var command = args[0].ToLowerInvariant(); + return command switch + { + "serve" => await ServeAsync(args[1..]).ConfigureAwait(false), + "status" => await AdminCommandAsync(new JsonObject { ["cmd"] = "status" }, args[1..]).ConfigureAwait(false), + "clients" or "users" => await AdminCommandAsync(new JsonObject { ["cmd"] = "clients" }, args[1..]).ConfigureAwait(false), + "bans" => await AdminCommandAsync(new JsonObject { ["cmd"] = "bans" }, args[1..]).ConfigureAwait(false), + "kick" => await KickAsync(args[1..]).ConfigureAwait(false), + "ban" => await BanAsync(args[1..]).ConfigureAwait(false), + "unban" => await UnbanAsync(args[1..]).ConfigureAwait(false), + "world" => await WorldAsync(args[1..]).ConfigureAwait(false), + "config" => ConfigCommand(args[1..]), + "load-test" => await LoadTestAsync(args[1..]).ConfigureAwait(false), + _ => Unknown(command) + }; + } + + private static async Task ServeAsync(string[] args) + { + var configPath = GetOption(args, "--config", "-c") ?? "commonwealth-server.json"; + var fullConfigPath = Path.GetFullPath(configPath); + if (!File.Exists(fullConfigPath)) + { + Console.WriteLine($"Generating default configuration at {fullConfigPath}"); + ServerOptions.CreateDefault(fullConfigPath); + } + var options = ServerOptions.Load(fullConfigPath); + if (GetOption(args, "--host", "-H") is { } host) options.Host = host; + if (GetOption(args, "--port", "-p") is { } portText) + { + if (!int.TryParse(portText, out var port)) throw new ArgumentException("--port requires an integer."); + options.Port = port; + } + var errors = options.Validate(); + if (errors.Count > 0) + { + foreach (var error in errors) Console.Error.WriteLine($"[ERROR] {error}"); + return 2; + } + + await using var runtime = new ServerRuntime(options); + runtime.Server.LogMessage += (message, level) => + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + Console.WriteLine($"{timestamp} {level.ToUpperInvariant()} {message}"); + }; + + runtime.Start(); + PrintStartup(options); + using var shutdown = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; shutdown.Cancel(); }; + PosixSignalRegistration? sigterm = null; + if (!OperatingSystem.IsWindows()) + { + sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; shutdown.Cancel(); }); + } + try + { + if (HasFlag(args, "--interactive", "-i") && !Console.IsInputRedirected) + await RunInteractiveAsync(options, shutdown).ConfigureAwait(false); + else + await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) { } + finally { sigterm?.Dispose(); } + return 0; + } + + private static async Task RunInteractiveAsync(ServerOptions options, CancellationTokenSource shutdown) + { + Console.WriteLine("Type help for commands. Type quit to stop."); + while (!shutdown.IsCancellationRequested) + { + Console.Write("commonwealth> "); + var line = Console.ReadLine(); + if (line is null) break; + var parts = SplitCommandLine(line); + if (parts.Length == 0) continue; + if (parts[0] is "quit" or "exit" or "stop") { shutdown.Cancel(); break; } + if (parts[0] == "help") { PrintInteractiveHelp(); continue; } + var forwarded = parts[0] switch + { + "users" => new JsonObject { ["cmd"] = "clients" }, + "clients" => new JsonObject { ["cmd"] = "clients" }, + "status" => new JsonObject { ["cmd"] = "status" }, + "bans" => new JsonObject { ["cmd"] = "bans" }, + _ => null + }; + if (forwarded is not null) + { + await PrintAdminResponseAsync(forwarded, options).ConfigureAwait(false); + continue; + } + if (parts[0] == "kick" && parts.Length >= 2 && uint.TryParse(parts[1], out var kickId)) + { + await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = kickId, ["reason"] = ReadReason(parts, 2) }, options).ConfigureAwait(false); + continue; + } + if (parts[0] == "ban" && parts.Length >= 2) + { + var request = new JsonObject { ["cmd"] = "ban", ["reason"] = ReadReason(parts, 2) }; + if (uint.TryParse(parts[1], out var banId)) request["playerId"] = banId; else request["ip"] = parts[1]; + await PrintAdminResponseAsync(request, options).ConfigureAwait(false); + continue; + } + if (parts[0] == "unban" && parts.Length >= 2) + { + await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "unban", ["ip"] = parts[1] }, options).ConfigureAwait(false); + continue; + } + if (parts.Length >= 3 && parts[0] == "world" && parts[1] == "time") + { + await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "world_time", ["hhmm"] = parts[2] }, options).ConfigureAwait(false); + continue; + } + if (parts.Length >= 3 && parts[0] == "world" && parts[1] == "weather") + { + await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "world_weather", ["weather"] = parts[2] }, options).ConfigureAwait(false); + continue; + } + Console.WriteLine("Unknown command. Type help."); + } + } + + private static async Task AdminCommandAsync(JsonObject request, string[] args) + { + var options = LoadOptionsForAdmin(args); + return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1; + } + + private static async Task KickAsync(string[] args) + { + if (args.Length == 0 || !uint.TryParse(args[0], out var id)) throw new ArgumentException("kick requires PLAYER_ID"); + var options = LoadOptionsForAdmin(args[1..]); + var request = new JsonObject { ["cmd"] = "kick", ["playerId"] = id, ["reason"] = GetOption(args, "--reason") ?? string.Empty }; + return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1; + } + + private static async Task BanAsync(string[] args) + { + if (args.Length == 0) throw new ArgumentException("ban requires PLAYER_ID_OR_IP"); + var request = new JsonObject { ["cmd"] = "ban", ["reason"] = GetOption(args, "--reason") ?? string.Empty }; + if (uint.TryParse(args[0], out var id)) request["playerId"] = id; else request["ip"] = args[0]; + var options = LoadOptionsForAdmin(args[1..]); + return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1; + } + + private static async Task UnbanAsync(string[] args) + { + if (args.Length == 0) throw new ArgumentException("unban requires IP"); + var options = LoadOptionsForAdmin(args[1..]); + return await PrintAdminResponseAsync(new JsonObject { ["cmd"] = "unban", ["ip"] = args[0] }, options).ConfigureAwait(false) ? 0 : 1; + } + + private static async Task WorldAsync(string[] args) + { + if (args.Length < 2) throw new ArgumentException("world requires 'time HHmm' or 'weather FORM_ID'"); + var request = args[0].ToLowerInvariant() switch + { + "time" => new JsonObject { ["cmd"] = "world_time", ["hhmm"] = args[1] }, + "weather" => new JsonObject { ["cmd"] = "world_weather", ["weather"] = args[1] }, + _ => throw new ArgumentException("world requires 'time' or 'weather'") + }; + var options = LoadOptionsForAdmin(args[2..]); + return await PrintAdminResponseAsync(request, options).ConfigureAwait(false) ? 0 : 1; + } + + private static int ConfigCommand(string[] args) + { + if (args.Length == 0 || args[0] != "init") throw new ArgumentException("config requires 'init [OUTPUT_PATH]'"); + var path = Path.GetFullPath(args.Length > 1 ? args[1] : "commonwealth-server.json"); + if (File.Exists(path)) throw new IOException($"Config already exists: {path}"); + ServerOptions.CreateDefault(path); + Console.WriteLine(path); + return 0; + } + + private static async Task LoadTestAsync(string[] args) + { + var host = GetOption(args, "--host", "-H") ?? "127.0.0.1"; + var port = int.TryParse(GetOption(args, "--port", "-p"), out var parsedPort) ? parsedPort : 7777; + var count = int.TryParse(GetOption(args, "--clients", "-n"), out var parsedCount) ? parsedCount : 16; + if (count is < 1 or > 256) throw new ArgumentOutOfRangeException(nameof(count), "client count must be 1-256"); + var clients = new List(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + try + { + for (var i = 0; i < count; i++) + { + var client = new SyntheticProtocolClient(); + await client.ConnectAsync(host, port, timeout.Token).ConfigureAwait(false); + clients.Add(client); + var cell = (0x1000 + i).ToString("X8"); + await client.SendTransformAsync(i * 1000.0, 0, 0, cell, string.Empty, "spawn", timeout.Token).ConfigureAwait(false); + } + var ids = clients.Select(x => x.PlayerId).ToArray(); + if (ids.Distinct().Count() != ids.Length) throw new InvalidOperationException("Server assigned duplicate player IDs."); + Console.WriteLine($"Connected {clients.Count} clients with unique server-owned IDs: {string.Join(", ", ids)}"); + return 0; + } + finally + { + foreach (var client in clients) await client.DisposeAsync(); + } + } + + private static ServerOptions LoadOptionsForAdmin(string[] args) + { + var configPath = GetOption(args, "--config", "-c") ?? "commonwealth-server.json"; + return ServerOptions.Load(Path.GetFullPath(configPath)); + } + + private static async Task PrintAdminResponseAsync(JsonObject request, ServerOptions options) + { + JsonObject response; + try { response = await AdminClient.SendAsync(request, options.AdminPort, options.AdminTokenPath).ConfigureAwait(false); } + catch (Exception ex) + { + Console.Error.WriteLine($"Error talking to admin port 127.0.0.1:{options.AdminPort}: {ex.Message}"); + return false; + } + Console.WriteLine(response.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + return JsonHelpers.Boolean(response["ok"]) == true; + } + + private static void PrintStartup(ServerOptions options) + { + Console.WriteLine($"Commonwealth Online Server\nBind: {options.Host}:{options.Port}\nMax players: {options.MaxPlayers}\nLAN discovery: UDP {LanDiscoveryService.DiscoveryPort}\nAdmin: 127.0.0.1:{options.AdminPort}\nGNS: {(options.EnableGnsTransport ? "enabled" : "disabled")}\n"); + } + + private static string? GetOption(string[] args, params string[] names) + { + for (var i = 0; i < args.Length; i++) + { + foreach (var name in names) + { + if (args[i] == name) + { + if (i + 1 >= args.Length) throw new ArgumentException($"{name} requires a value."); + return args[i + 1]; + } + if (args[i].StartsWith(name + "=", StringComparison.Ordinal)) return args[i][(name.Length + 1)..]; + } + } + return null; + } + + private static bool HasFlag(string[] args, params string[] names) => args.Any(arg => names.Contains(arg, StringComparer.Ordinal)); + + private static string[] SplitCommandLine(string input) + { + var result = new List(); + var current = new System.Text.StringBuilder(); + var quoted = false; + for (var i = 0; i < input.Length; i++) + { + var ch = input[i]; + if (ch == '"') { quoted = !quoted; continue; } + if (char.IsWhiteSpace(ch) && !quoted) + { + if (current.Length > 0) { result.Add(current.ToString()); current.Clear(); } + continue; + } + current.Append(ch); + } + if (current.Length > 0) result.Add(current.ToString()); + return result.ToArray(); + } + + private static string ReadReason(string[] parts, int start) + { + var index = Array.IndexOf(parts, "--reason", start); + return index >= 0 && index + 1 < parts.Length ? parts[index + 1] : string.Empty; + } + + private static int Unknown(string command) + { + Console.Error.WriteLine($"Unknown command: {command}"); + PrintHelp(); + return 2; + } + + private static void PrintHelp() + { + Console.WriteLine(""" +Commonwealth Online Server + +Commands: + serve [--config PATH] [--host HOST] [--port PORT] [--interactive] + status [--config PATH] + clients [--config PATH] + users [--config PATH] + kick PLAYER_ID [--reason TEXT] [--config PATH] + ban PLAYER_ID_OR_IP [--reason TEXT] [--config PATH] + unban IP [--config PATH] + bans [--config PATH] + world time HHmm [--config PATH] + world weather FORM_ID [--config PATH] + config init [OUTPUT_PATH] + load-test [--host HOST] [--port PORT] [--clients N] +"""); + } + + private static void PrintInteractiveHelp() + { + Console.WriteLine("help | status | users | kick ID [--reason TEXT] | ban ID_OR_IP [--reason TEXT] | unban IP | bans | world time HHmm | world weather FORM_ID | quit"); + } +} diff --git a/server/ProtocolCore.cs b/server/ProtocolCore.cs new file mode 100644 index 0000000..2f6558b --- /dev/null +++ b/server/ProtocolCore.cs @@ -0,0 +1,178 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal static class ProtocolConstants +{ + public const int ProtocolVersion = 2; + public const int LegacyProtocolVersion = 1; + public const int MaxMessageBytes = 64 * 1024; + public const double MaxAbsCoordinate = 10_000_000.0; + public const double MaxMovementSpeed = 100_000.0; + public const int MaxActionEvents = 16; + public const int MaxNpcsPerPacket = 64; + public const double ExteriorInterestRadius = 8192.0; + public const double MaxNormalMovementSpeed = 2500.0; + public const double MovementGraceDistance = 512.0; + public const double MaxMovementValidationElapsedSeconds = 5.0; + + public static readonly HashSet MovementTransitionTypes = new(StringComparer.Ordinal) + { + "teleport", "cell_change", "worldspace_change", "load", "spawn", "fast_travel" + }; + + public static readonly HashSet AllowedMovementTypes = new(MovementTransitionTypes, StringComparer.Ordinal) { "normal" }; +} + +internal enum Delivery : uint { UnreliableSequenced = 0, ReliableOrdered = 1 } +internal readonly record struct EncodedPacket(string PacketType, byte[] Payload, Delivery Delivery); + +internal static class TransportPolicy +{ + public static Delivery ForPacketType(string packetType) => packetType is "transform" or "npcState" ? Delivery.UnreliableSequenced : Delivery.ReliableOrdered; + public static bool IsSnapshot(string packetType) => ForPacketType(packetType) == Delivery.UnreliableSequenced; +} + +internal sealed class PacketCodecException(string message) : Exception(message); + +internal static class PacketCodec +{ + private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false }; + + public static EncodedPacket Encode(JsonObject packet) + { + var type = JsonHelpers.String(packet["type"]); + if (string.IsNullOrEmpty(type)) throw new PacketCodecException("packet type must be a non-empty string"); + byte[] payload; + try { payload = JsonSerializer.SerializeToUtf8Bytes(packet, Compact); } + catch (Exception ex) when (ex is JsonException or NotSupportedException) { throw new PacketCodecException($"packet is not JSON serializable: {ex.Message}"); } + if (payload.Length > ProtocolConstants.MaxMessageBytes) throw new PacketCodecException("packet exceeds maximum message size"); + return new EncodedPacket(type, payload, TransportPolicy.ForPacketType(type)); + } + + public static JsonObject Decode(ReadOnlySpan payload) + { + if (payload.Length > ProtocolConstants.MaxMessageBytes) throw new PacketCodecException("packet exceeds maximum message size"); + JsonNode? node; + try + { + node = JsonNode.Parse(payload, documentOptions: new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow }); + } + catch (JsonException ex) { throw new PacketCodecException($"invalid JSON packet: {ex.Message}"); } + if (node is not JsonObject packet) throw new PacketCodecException("packet must be a JSON object"); + var type = JsonHelpers.String(packet["type"]); + if (string.IsNullOrEmpty(type)) throw new PacketCodecException("packet type must be a non-empty string"); + return packet; + } +} + +internal static class JsonHelpers +{ + public static string? String(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : null; + public static bool? Boolean(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var result) ? result : null; + + public static bool TryUInt32(JsonNode? node, uint min, uint max, out uint result) + { + result = 0; + if (node is not JsonValue value || value.TryGetValue(out _)) return false; + if (value.TryGetValue(out var u) && u >= min && u <= max) { result = u; return true; } + if (value.TryGetValue(out var i) && i >= 0 && (uint)i >= min && (uint)i <= max) { result = (uint)i; return true; } + if (value.TryGetValue(out var l) && l >= min && l <= max) { result = (uint)l; return true; } + if (value.TryGetValue(out var d) && double.IsFinite(d) && d == Math.Truncate(d) && d >= min && d <= max) { result = (uint)d; return true; } + return false; + } + + public static bool TryDouble(JsonNode? node, double min, double max, out double result) + { + result = 0; + if (node is not JsonValue value || value.TryGetValue(out _)) return false; + double d; + if (value.TryGetValue(out var direct)) d = direct; + else if (value.TryGetValue(out var l)) d = l; + else if (value.TryGetValue(out var iv)) d = iv; + else if (value.TryGetValue(out var m)) d = (double)m; + else return false; + if (!double.IsFinite(d) || d < min || d > max) return false; + result = d; + return true; + } + + public static bool IsHexFormId(JsonNode? node, bool allowEmpty = false, bool allowZero = true) => String(node) is { } text && IsHexFormId(text, allowEmpty, allowZero); + public static bool IsHexFormId(string text, bool allowEmpty = false, bool allowZero = true) + { + if (allowEmpty && text.Length == 0) return true; + if (text.Length is < 1 or > 8) return false; + if (!uint.TryParse(text, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) return false; + return allowZero || parsed != 0; + } + public static string NormalizeFormId(string text) => uint.Parse(text, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture).ToString("X8", CultureInfo.InvariantCulture); + public static JsonObject CloneObject(JsonObject source) => (JsonObject)source.DeepClone(); + public static double UnixTime() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0; +} + +internal sealed class SequenceCounter +{ + private uint _value; + public uint Current => _value; + public uint Advance() { unchecked { _value++; } if (_value == 0) _value = 1; return _value; } + public void Reset() => _value = 0; +} + +internal sealed class SequenceWindow +{ + private uint _lastAccepted; + public uint LastAccepted => _lastAccepted; + public bool Accept(uint candidate) { if (!IsNewer(candidate, _lastAccepted)) return false; _lastAccepted = candidate; return true; } + public void Reset() => _lastAccepted = 0; + public static bool IsNewer(uint candidate, uint baseline) + { + if (candidate == 0) return false; + if (baseline == 0) return true; + var delta = unchecked(candidate - baseline); + return delta > 0 && delta < 0x80000000u; + } +} + +internal readonly record struct SnapshotEnvelope(string PacketType, uint Sequence, byte[] Payload); + +internal static class GnsSnapshotEnvelope +{ + private static ReadOnlySpan Magic => "COG2"u8; + private const byte Version = 1; + public const int HeaderSize = 12; + + public static byte[] Encode(string packetType, ReadOnlySpan payload, uint sequence) + { + var family = packetType switch { "transform" => (byte)1, "npcState" => (byte)2, _ => throw new ArgumentException($"packet type '{packetType}' is not a GNS snapshot family", nameof(packetType)) }; + if (sequence == 0) throw new ArgumentOutOfRangeException(nameof(sequence)); + if (payload.IsEmpty) throw new ArgumentException("snapshot payload cannot be empty", nameof(payload)); + if (HeaderSize + payload.Length > ProtocolConstants.MaxMessageBytes) throw new ArgumentException("snapshot envelope exceeds maximum GNS message size"); + var output = new byte[HeaderSize + payload.Length]; + Magic.CopyTo(output); + output[4] = Version; + output[5] = family; + BinaryPrimitives.WriteUInt16BigEndian(output.AsSpan(6, 2), 0); + BinaryPrimitives.WriteUInt32BigEndian(output.AsSpan(8, 4), sequence); + payload.CopyTo(output.AsSpan(HeaderSize)); + return output; + } + + public static bool TryDecode(ReadOnlySpan message, out SnapshotEnvelope envelope, out string? error) + { + envelope = default; error = null; + if (message.Length < 4 || !message[..4].SequenceEqual(Magic)) return false; + if (message.Length < HeaderSize) { error = "truncated GNS snapshot envelope"; return true; } + if (message[4] != Version || BinaryPrimitives.ReadUInt16BigEndian(message.Slice(6, 2)) != 0) { error = "invalid GNS snapshot envelope header"; return true; } + var type = message[5] switch { 1 => "transform", 2 => "npcState", _ => null }; + if (type is null) { error = "unknown GNS snapshot family"; return true; } + var sequence = BinaryPrimitives.ReadUInt32BigEndian(message.Slice(8, 4)); + if (sequence == 0) { error = "snapshot sequence zero is reserved"; return true; } + var payload = message[HeaderSize..].ToArray(); + if (payload.Length == 0) { error = "snapshot envelope payload is empty"; return true; } + envelope = new SnapshotEnvelope(type, sequence, payload); + return true; + } +} diff --git a/server/ProtocolValidation.cs b/server/ProtocolValidation.cs new file mode 100644 index 0000000..ef7b1d6 --- /dev/null +++ b/server/ProtocolValidation.cs @@ -0,0 +1,341 @@ +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal static class ProtocolValidation +{ + private const int MaxCharacterNameChars = 128; + private const int MaxEquippedItems = 32; + private const int MaxEquipmentSlotChars = 64; + private const int MaxHeadParts = 64; + private const int MaxMorphs = 128; + private const int MaxMorphRegions = 128; + private const int MaxFacialBoneMorphs = 128; + private const int MaxTints = 128; + + public static JsonArray NormalizeActionEvents(JsonNode? value, bool strict) + { + var output = new JsonArray(); + if (value is not JsonArray input) return output; + var count = Math.Min(input.Count, ProtocolConstants.MaxActionEvents); + for (var i = 0; i < count; i++) + { + if (input[i] is not JsonObject item) + { + if (strict) return new JsonArray(); + continue; + } + if (!JsonHelpers.TryUInt32(item["sequence"], 1, uint.MaxValue, out var sequence) || + !JsonHelpers.TryUInt32(item["type"], 1, 3, out var actionType) || + JsonHelpers.String(item["eventName"]) is not { } eventName || + !((actionType is 1 or 2 && eventName == "meleeattackStart") || (actionType == 3 && eventName == "fireSingle"))) + { + if (strict) return new JsonArray(); + continue; + } + var clean = new JsonObject { ["sequence"] = sequence, ["type"] = actionType, ["eventName"] = eventName }; + foreach (var name in new[] { "actorStateFlags1", "actorStateFlags2" }) + if (JsonHelpers.TryUInt32(item[name], 0, uint.MaxValue, out var flags)) clean[name] = flags; + output.Add(clean); + } + return output; + } + + public static JsonObject? NormalizePlayerState(JsonObject packet) + { + var clean = new JsonObject { ["type"] = "playerState" }; + var hasState = false; + if (packet.ContainsKey("equippedItems")) + { + if (packet["equippedItems"] is not JsonArray items || items.Count > MaxEquippedItems) return null; + var cleanItems = new JsonArray(); + foreach (var node in items) + { + if (node is not JsonObject item) return null; + var slot = JsonHelpers.String(item["slot"]); + var form = JsonHelpers.String(item["formId"]) ?? string.Empty; + if (slot is null || slot.Length is < 1 or > MaxEquipmentSlotChars || !JsonHelpers.IsHexFormId(form, true, true)) return null; + cleanItems.Add(new JsonObject { ["slot"] = slot, ["formId"] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form) }); + } + clean["equippedItems"] = cleanItems; + hasState = true; + } + if (packet.ContainsKey("appearance")) + { + if (packet["appearance"] is not JsonObject appearance) return null; + var normalizedAppearance = NormalizeAppearance(appearance); + if (normalizedAppearance is null) return null; + clean["appearance"] = normalizedAppearance; + hasState = true; + } + if (packet.ContainsKey("actionEvents")) + { + if (packet["actionEvents"] is not JsonArray actions) return null; + var normalized = NormalizeActionEvents(actions, true); + if (normalized.Count != actions.Count) return null; + clean["actionEvents"] = normalized; + hasState = true; + } + if (packet.ContainsKey("characterName")) + { + var name = JsonHelpers.String(packet["characterName"]); + if (name is null || name.Length > MaxCharacterNameChars) return null; + clean["characterName"] = name; + hasState = true; + } + return hasState ? clean : null; + } + + private static JsonObject? NormalizeAppearance(JsonObject value) + { + var clean = new JsonObject(); + uint version = 4; + if (value.ContainsKey("version") && !JsonHelpers.TryUInt32(value["version"], 1, 1000, out version)) return null; + clean["version"] = version; + foreach (var name in new[] { "raceFormId", "hairColorFormId", "facialHairColorFormId", "complexionFormId" }) + { + if (!value.ContainsKey(name)) continue; + var form = JsonHelpers.String(value[name]); + if (form is null || !JsonHelpers.IsHexFormId(form, true, true)) return null; + clean[name] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form); + } + if (value.ContainsKey("height")) + { + if (!JsonHelpers.TryDouble(value["height"], 0.25, 4.0, out var height)) return null; + clean["height"] = height; + } + if (value.ContainsKey("isFemale")) + { + var female = JsonHelpers.Boolean(value["isFemale"]); + if (female is null) return null; + clean["isFemale"] = female.Value; + } + if (value.ContainsKey("morphWeight")) + { + if (value["morphWeight"] is not JsonObject weights) return null; + var normalized = new JsonObject(); + foreach (var name in new[] { "thin", "muscular", "large" }) + { + if (!JsonHelpers.TryDouble(weights[name], -100, 100, out var weight)) return null; + normalized[name] = weight; + } + clean["morphWeight"] = normalized; + } + if (value.ContainsKey("bodyTintColor")) + { + if (value["bodyTintColor"] is not JsonObject color) return null; + var normalized = new JsonObject(); + foreach (var name in new[] { "r", "g", "b", "a" }) + { + if (!JsonHelpers.TryUInt32(color[name], 0, 255, out var component)) return null; + normalized[name] = component; + } + clean["bodyTintColor"] = normalized; + } + if (value.ContainsKey("headParts")) + { + if (value["headParts"] is not JsonArray parts || parts.Count > MaxHeadParts) return null; + var normalized = new JsonArray(); + foreach (var node in parts) + { + var form = JsonHelpers.String(node); + if (form is null || !JsonHelpers.IsHexFormId(form, false, true)) return null; + normalized.Add(JsonHelpers.NormalizeFormId(form)); + } + clean["headParts"] = normalized; + } + if (value.ContainsKey("morphs")) + { + if (value["morphs"] is not JsonArray morphs || morphs.Count > MaxMorphs) return null; + var normalized = new JsonArray(); + foreach (var node in morphs) + { + if (node is not JsonObject morph) return null; + var id = JsonHelpers.String(morph["id"]); + if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || !JsonHelpers.TryDouble(morph["value"], -1000, 1000, out var amount)) return null; + normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["value"] = amount }); + } + clean["morphs"] = normalized; + } + if (value.ContainsKey("morphRegions")) + { + if (value["morphRegions"] is not JsonArray regions || regions.Count > MaxMorphRegions) return null; + var normalized = new JsonArray(); + foreach (var node in regions) + { + if (!JsonHelpers.TryDouble(node, -1000, 1000, out var amount)) return null; + normalized.Add(amount); + } + clean["morphRegions"] = normalized; + } + if (value.ContainsKey("facialBoneMorphs")) + { + if (value["facialBoneMorphs"] is not JsonArray morphs || morphs.Count > MaxFacialBoneMorphs) return null; + var normalized = new JsonArray(); + foreach (var node in morphs) + { + if (node is not JsonObject morph) return null; + var id = JsonHelpers.String(morph["id"]); + var position = NormalizeVec3(morph["position"], -10000, 10000); + var rotation = NormalizeVec3(morph["rotation"], -10000, 10000); + var scale = NormalizeVec3(morph["scale"], -100, 100); + if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || position is null || rotation is null || scale is null) return null; + normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["position"] = position, ["rotation"] = rotation, ["scale"] = scale }); + } + clean["facialBoneMorphs"] = normalized; + } + if (value.ContainsKey("tints")) + { + if (value["tints"] is not JsonArray tints || tints.Count > MaxTints) return null; + var normalized = new JsonArray(); + foreach (var node in tints) + { + if (node is not JsonObject tint || !JsonHelpers.TryUInt32(tint["id"], 0, ushort.MaxValue, out var id) || + !JsonHelpers.TryUInt32(tint["type"], 0, uint.MaxValue, out var type) || !JsonHelpers.TryUInt32(tint["value"], 0, 255, out var amount)) return null; + var cleanTint = new JsonObject { ["id"] = id, ["type"] = type, ["value"] = amount }; + if (tint.ContainsKey("color")) + { + var color = JsonHelpers.String(tint["color"]); + var swatchNode = tint["swatch"] ?? JsonValue.Create(0); + if (color is null || !JsonHelpers.IsHexFormId(color, false, true) || !JsonHelpers.TryUInt32(swatchNode, 0, ushort.MaxValue, out var swatch)) return null; + cleanTint["color"] = JsonHelpers.NormalizeFormId(color); + cleanTint["swatch"] = swatch; + } + normalized.Add(cleanTint); + } + clean["tints"] = normalized; + } + return clean; + } + + private static JsonArray? NormalizeVec3(JsonNode? node, double min, double max) + { + if (node is not JsonArray array || array.Count != 3) return null; + var output = new JsonArray(); + foreach (var component in array) + { + if (!JsonHelpers.TryDouble(component, min, max, out var value)) return null; + output.Add(value); + } + return output; + } + + public static JsonObject? NormalizeTransform(JsonObject packet) + { + foreach (var field in new[] { "x", "y", "z", "angleZ" }) + if (!JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null; + var cell = JsonHelpers.String(packet["cellId"]); + var world = JsonHelpers.String(packet["worldspaceId"]) ?? string.Empty; + if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null; + var movementType = JsonHelpers.String(packet["movementType"]) ?? "normal"; + if (!ProtocolConstants.AllowedMovementTypes.Contains(movementType)) return null; + var normalized = JsonHelpers.CloneObject(packet); + foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); normalized[field] = v; } + normalized["cellId"] = JsonHelpers.NormalizeFormId(cell); + normalized["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); + normalized["movementType"] = movementType; + foreach (var field in new[] { "movementSpeed", "animationGraphSpeed" }) + { + if (!packet.ContainsKey(field)) continue; + var min = field == "animationGraphSpeed" ? -1.0 : 0.0; + if (JsonHelpers.TryDouble(packet[field], min, ProtocolConstants.MaxMovementSpeed, out var value)) normalized[field] = value; else normalized.Remove(field); + } + foreach (var (field, min, max) in new[] { ("animationDirection", -360.0, 360.0), ("aimPitch", -180.0, 180.0), ("turnDelta", -10000.0, 10000.0) }) + { + if (!packet.ContainsKey(field)) continue; + if (JsonHelpers.TryDouble(packet[field], min, max, out var value)) normalized[field] = value; else normalized.Remove(field); + } + foreach (var field in new[] { "isMoving", "isSprinting", "isSneaking", "isJumping", "isCrouching", "weaponDrawn" }) + if (packet.ContainsKey(field) && JsonHelpers.Boolean(packet[field]) is null) normalized.Remove(field); + foreach (var field in new[] { "actorStateFlags1", "actorStateFlags2" }) + if (packet.ContainsKey(field) && !JsonHelpers.TryUInt32(packet[field], 0, uint.MaxValue, out _)) normalized.Remove(field); + if (packet.ContainsKey("actionEvents")) normalized["actionEvents"] = NormalizeActionEvents(packet["actionEvents"], false); + return normalized; + } + + public static (bool Accepted, string Reason) ValidateMovement(JsonObject? previous, double? previousMonotonic, JsonObject current, double nowMonotonic) + { + if (previous is null || previousMonotonic is null) return (true, "first transform"); + var movementType = JsonHelpers.String(current["movementType"]) ?? "normal"; + if (ProtocolConstants.MovementTransitionTypes.Contains(movementType)) return (true, $"explicit {movementType} transition"); + if ((JsonHelpers.String(previous["cellId"]) ?? string.Empty) != (JsonHelpers.String(current["cellId"]) ?? string.Empty) || + (JsonHelpers.String(previous["worldspaceId"]) ?? string.Empty) != (JsonHelpers.String(current["worldspaceId"]) ?? string.Empty)) + return (false, "scope changed without an explicit movement transition"); + var elapsed = nowMonotonic - previousMonotonic.Value; + if (!double.IsFinite(elapsed) || elapsed < 0) elapsed = 0; + elapsed = Math.Min(elapsed, ProtocolConstants.MaxMovementValidationElapsedSeconds); + JsonHelpers.TryDouble(current["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x); + JsonHelpers.TryDouble(current["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y); + JsonHelpers.TryDouble(current["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var z); + JsonHelpers.TryDouble(previous["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var px); + JsonHelpers.TryDouble(previous["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var py); + JsonHelpers.TryDouble(previous["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var pz); + var dx = x - px; var dy = y - py; var dz = z - pz; + var distance = Math.Sqrt(dx * dx + dy * dy + dz * dz); + var allowed = ProtocolConstants.MovementGraceDistance + ProtocolConstants.MaxNormalMovementSpeed * elapsed; + return double.IsFinite(distance) && distance <= allowed ? (true, "normal movement accepted") : (false, $"normal movement exceeded server envelope: distance={distance:F1}, allowed={allowed:F1}, elapsed={elapsed:F3}s"); + } + + public static JsonObject? NormalizeWorldState(JsonObject packet) + { + var normalized = JsonHelpers.CloneObject(packet); + if (packet.ContainsKey("gameHour")) { if (!JsonHelpers.TryDouble(packet["gameHour"], 0, 24, out var hour)) return null; normalized["gameHour"] = hour; } + if (packet.ContainsKey("gameDaysPassed")) { if (!JsonHelpers.TryDouble(packet["gameDaysPassed"], 0, 10_000_000, out var days)) return null; normalized["gameDaysPassed"] = days; } + if (packet.ContainsKey("weatherFormId")) + { + var weather = JsonHelpers.String(packet["weatherFormId"]); + if (weather is null || !JsonHelpers.IsHexFormId(weather, true, true)) return null; + normalized["weatherFormId"] = weather.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(weather); + } + return normalized; + } + + public static JsonObject? NormalizeNpcState(JsonObject packet) + { + if (packet["npcs"] is not JsonArray npcs || npcs.Count > ProtocolConstants.MaxNpcsPerPacket) return null; + var cleanNpcs = new JsonArray(); + foreach (var node in npcs) + { + if (node is not JsonObject npc) return null; + var source = JsonHelpers.String(npc["sourceFormId"]); var cell = JsonHelpers.String(npc["cellId"]); var world = JsonHelpers.String(npc["worldspaceId"]) ?? string.Empty; + if (source is null || cell is null || !JsonHelpers.IsHexFormId(source, false, false) || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null; + foreach (var field in new[] { "x", "y", "z", "angleZ" }) if (!JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null; + var clean = JsonHelpers.CloneObject(npc); + clean["sourceFormId"] = JsonHelpers.NormalizeFormId(source); clean["cellId"] = JsonHelpers.NormalizeFormId(cell); clean["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); + foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); clean[field] = v; } + cleanNpcs.Add(clean); + } + var normalized = JsonHelpers.CloneObject(packet); normalized["npcs"] = cleanNpcs; + if (packet.ContainsKey("authorityEpoch")) { if (!JsonHelpers.TryUInt32(packet["authorityEpoch"], 1, uint.MaxValue, out var epoch)) return null; normalized["authorityEpoch"] = epoch; } + if (packet.ContainsKey("authorityCellId")) { var cell = JsonHelpers.String(packet["authorityCellId"]); if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false)) return null; normalized["authorityCellId"] = JsonHelpers.NormalizeFormId(cell); } + if (packet.ContainsKey("authorityWorldspaceId")) { var world = JsonHelpers.String(packet["authorityWorldspaceId"]); if (world is null || !JsonHelpers.IsHexFormId(world, true, true)) return null; normalized["authorityWorldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); } + return normalized; + } + + public static JsonObject? NormalizeCombatHit(JsonObject packet) + { + if (!JsonHelpers.TryUInt32(packet["targetPlayerId"], 1, uint.MaxValue, out var target) || !JsonHelpers.TryUInt32(packet["sequence"], 1, uint.MaxValue, out var sequence) || !JsonHelpers.TryDouble(packet["damage"], 0.000001, 10000, out var damage)) return null; + var normalized = JsonHelpers.CloneObject(packet); normalized["targetPlayerId"] = target; normalized["sequence"] = sequence; normalized["damage"] = damage; + if (packet.ContainsKey("weaponFormId")) { var weapon = JsonHelpers.String(packet["weaponFormId"]); if (weapon is null || !JsonHelpers.IsHexFormId(weapon, false, true)) return null; normalized["weaponFormId"] = JsonHelpers.NormalizeFormId(weapon); } + return normalized; + } + + public static StateScope? ScopeFromState(JsonObject? state) + { + if (state is null) return null; + var cell = JsonHelpers.String(state["cellId"]); var world = JsonHelpers.String(state["worldspaceId"]) ?? string.Empty; + if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null; + if (!JsonHelpers.TryDouble(state["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x) || !JsonHelpers.TryDouble(state["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y)) return null; + return new StateScope(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world), x, y); + } + + public static bool StatesShareInterest(JsonObject? a, JsonObject? b) + { + var left = ScopeFromState(a); var right = ScopeFromState(b); + if (left is null || right is null) return true; + if (left.Value.CellId == right.Value.CellId) return true; + if (string.IsNullOrEmpty(left.Value.WorldspaceId) || left.Value.WorldspaceId != right.Value.WorldspaceId) return false; + var dx = left.Value.X - right.Value.X; var dy = left.Value.Y - right.Value.Y; + return Math.Sqrt(dx * dx + dy * dy) <= ProtocolConstants.ExteriorInterestRadius; + } +} diff --git a/server/README.md b/server/README.md index 4f1e692..2a80ca1 100644 --- a/server/README.md +++ b/server/README.md @@ -1,152 +1,40 @@ -# 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. Valve GameNetworkingSockets remains in the native C++ bridge under `native_transport/`, and the C# server loads its C ABI directly. -## Quick Launch +## Run -### Windows - -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 +44,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 provides deterministic multi-client load coverage: ```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/ServerRuntime.cs b/server/ServerRuntime.cs new file mode 100644 index 0000000..1081479 --- /dev/null +++ b/server/ServerRuntime.cs @@ -0,0 +1,53 @@ +namespace CommonwealthOnline.Server; + +internal sealed class ServerRuntime : IAsyncDisposable +{ + private readonly ServerOptions _options; + private readonly AuthoritativeServer _server; + private readonly TcpServerTransport _tcp; + private readonly GnsServerTransport? _gns; + private readonly AdminControlServer _admin; + private readonly LanDiscoveryService _discovery; + private int _started; + + public ServerRuntime(ServerOptions options) + { + _options = options; + _server = new AuthoritativeServer(options); + _tcp = new TcpServerTransport(options, _server); + _gns = options.EnableGnsTransport ? new GnsServerTransport(options, _server) : null; + _admin = new AdminControlServer(_server, options); + _discovery = new LanDiscoveryService(_server, options); + } + + public AuthoritativeServer Server => _server; + + public void Start() + { + if (Interlocked.Exchange(ref _started, 1) != 0) return; + _admin.Start(); + try + { + _tcp.Start(); + _gns?.Start(); + try { _discovery.Start(); } + catch (Exception ex) { _server.Log($"LAN discovery unavailable on UDP {LanDiscoveryService.DiscoveryPort}: {ex.Message}. Direct connections still work.", "warning"); } + } + catch + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + throw; + } + _server.Log($"Commonwealth Online server listening on {_options.Host}:{_options.Port}"); + _server.Log($"Protocol v{ProtocolConstants.ProtocolVersion} negotiation enabled; legacy TCP clients remain temporarily compatible."); + } + + public async ValueTask DisposeAsync() + { + if (_gns is not null) await _gns.DisposeAsync(); + await _tcp.DisposeAsync(); + await _discovery.DisposeAsync(); + await _admin.DisposeAsync(); + await _server.DisposeAsync(); + } +} diff --git a/server/SyntheticProtocolClient.cs b/server/SyntheticProtocolClient.cs new file mode 100644 index 0000000..a26d406 --- /dev/null +++ b/server/SyntheticProtocolClient.cs @@ -0,0 +1,76 @@ +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CommonwealthOnline.Server; + +internal sealed class SyntheticProtocolClient : IAsyncDisposable +{ + private readonly TcpClient _client = new(); + private NetworkStream? _stream; + private readonly MemoryStream _buffer = new(); + + public uint PlayerId { get; private set; } + + public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default) + { + await _client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); + _client.NoDelay = true; + _stream = _client.GetStream(); + var welcome = await ReceiveAsync(cancellationToken).ConfigureAwait(false); + if (JsonHelpers.String(welcome["type"]) != "welcome") throw new InvalidDataException("Server did not send welcome packet."); + await SendAsync(new JsonObject { ["type"] = "hello", ["protocolVersion"] = ProtocolConstants.ProtocolVersion }, cancellationToken).ConfigureAwait(false); + while (true) + { + var packet = await ReceiveAsync(cancellationToken).ConfigureAwait(false); + if (JsonHelpers.String(packet["type"]) != "sessionReady") continue; + if (!JsonHelpers.TryUInt32(packet["playerId"], 1, uint.MaxValue, out var id)) throw new InvalidDataException("sessionReady did not contain a valid playerId."); + PlayerId = id; + break; + } + } + + public async Task SendAsync(JsonObject packet, CancellationToken cancellationToken = default) + { + if (_stream is null) throw new InvalidOperationException("Client is not connected."); + var encoded = JsonSerializer.SerializeToUtf8Bytes(packet); + if (encoded.Length > ProtocolConstants.MaxMessageBytes) throw new InvalidDataException("Synthetic packet exceeds maximum message size."); + await _stream.WriteAsync(encoded, cancellationToken).ConfigureAwait(false); + await _stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); + } + + public async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + if (_stream is null) throw new InvalidOperationException("Client is not connected."); + var one = new byte[1]; + _buffer.SetLength(0); + while (_buffer.Length <= ProtocolConstants.MaxMessageBytes) + { + var count = await _stream.ReadAsync(one, cancellationToken).ConfigureAwait(false); + if (count == 0) throw new EndOfStreamException("Server closed the connection."); + if (one[0] == (byte)'\n') + { + var data = _buffer.ToArray(); + if (data.Length > 0 && data[^1] == (byte)'\r') Array.Resize(ref data, data.Length - 1); + return JsonNode.Parse(data) as JsonObject ?? throw new InvalidDataException("Server message was not a JSON object."); + } + _buffer.WriteByte(one[0]); + } + throw new InvalidDataException("Server message exceeded maximum size."); + } + + public Task SendTransformAsync(double x, double y, double z, string cellId, string worldspaceId = "", string movementType = "normal", CancellationToken cancellationToken = default) => + SendAsync(new JsonObject + { + ["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = z, ["angleZ"] = 0.0, + ["cellId"] = cellId, ["worldspaceId"] = worldspaceId, ["movementType"] = movementType + }, cancellationToken); + + public async ValueTask DisposeAsync() + { + try { _stream?.Dispose(); } catch { } + try { _client.Dispose(); } catch { } + _buffer.Dispose(); + await ValueTask.CompletedTask; + } +} diff --git a/server/TcpTransport.cs b/server/TcpTransport.cs new file mode 100644 index 0000000..5e0df70 --- /dev/null +++ b/server/TcpTransport.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; + +namespace CommonwealthOnline.Server; + +internal sealed class TcpGameConnection : IGameConnection +{ + private readonly TcpClient _client; + private readonly NetworkStream _stream; + private readonly SemaphoreSlim _sendGate = new(1, 1); + private int _closed; + + public TcpGameConnection(TcpClient client) + { + _client = client; + _client.NoDelay = true; + _stream = client.GetStream(); + RemoteEndpoint = (IPEndPoint)(_client.Client.RemoteEndPoint ?? throw new InvalidOperationException("TCP remote endpoint missing")); + ConnectionKey = $"tcp:{RemoteEndpoint.Address}:{RemoteEndpoint.Port}:{Guid.NewGuid():N}"; + } + + public string ConnectionKey { get; } + public string TransportName => "tcp"; + public IPEndPoint RemoteEndpoint { get; } + public bool IsClosed => Volatile.Read(ref _closed) != 0; + internal NetworkStream Stream => _stream; + + public async ValueTask SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default) + { + if (IsClosed) return SendOutcome.NotConnected; + if (packet.Payload.Length > ProtocolConstants.MaxMessageBytes) return SendOutcome.TooLarge; + if (packet.Payload.AsSpan().IndexOf((byte)'\n') >= 0 || packet.Payload.AsSpan().IndexOf((byte)'\r') >= 0) return SendOutcome.Error; + await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (IsClosed) return SendOutcome.NotConnected; + await _stream.WriteAsync(packet.Payload, cancellationToken).ConfigureAwait(false); + await _stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); + return SendOutcome.Sent; + } + catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException) + { + return SendOutcome.NotConnected; + } + finally { _sendGate.Release(); } + } + + public ValueTask DisconnectAsync(int reason, string debug) + { + if (Interlocked.Exchange(ref _closed, 1) != 0) return ValueTask.CompletedTask; + try { _client.Client.Shutdown(SocketShutdown.Both); } catch { } + try { _client.Close(); } catch { } + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync(0, "dispose"); + _sendGate.Dispose(); + _stream.Dispose(); + _client.Dispose(); + } +} + +internal sealed class TcpServerTransport : IAsyncDisposable +{ + private readonly ServerOptions _options; + private readonly IServerIngress _server; + private readonly CancellationTokenSource _shutdown = new(); + private readonly ConcurrentDictionary _clientTasks = new(); + private TcpListener? _listener; + private Task? _acceptTask; + + public TcpServerTransport(ServerOptions options, IServerIngress server) { _options = options; _server = server; } + + public void Start() + { + if (_acceptTask is not null) return; + if (!ServerOptions.TryResolveIpv4(_options.Host, out var address)) throw new InvalidOperationException($"Could not resolve IPv4 bind host {_options.Host}"); + _listener = new TcpListener(address, _options.Port); + _listener.Start(); + _acceptTask = Task.Run(() => AcceptLoopAsync(_shutdown.Token)); + _server.Log($"TCP compatibility transport listening on {_options.Host}:{_options.Port}"); + } + + private async Task AcceptLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client; + try { client = await _listener!.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException ex) + { + if (!cancellationToken.IsCancellationRequested) _server.Log($"TCP accept error: {ex.Message}", "warning"); + continue; + } + var connection = new TcpGameConnection(client); + bool accepted; + try { accepted = await _server.AcceptConnectionAsync(connection, cancellationToken).ConfigureAwait(false); } + catch (Exception ex) + { + _server.Log($"TCP admission failed for {connection.RemoteEndpoint}: {ex.Message}", "warning"); + await connection.DisposeAsync(); + continue; + } + if (!accepted) { await connection.DisposeAsync(); continue; } + var task = RunClientAsync(connection, cancellationToken); + _clientTasks[connection.ConnectionKey] = task; + _ = task.ContinueWith(_ => _clientTasks.TryRemove(connection.ConnectionKey, out _), TaskScheduler.Default); + } + } + + private async Task RunClientAsync(TcpGameConnection connection, CancellationToken cancellationToken) + { + var readBuffer = new byte[4096]; + using var message = new MemoryStream(4096); + try + { + while (!cancellationToken.IsCancellationRequested && !connection.IsClosed) + { + int count; + try { count = await connection.Stream.ReadAsync(readBuffer, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException) { break; } + if (count == 0) break; + for (var i = 0; i < count; i++) + { + var value = readBuffer[i]; + if (value == (byte)'\n') + { + var data = message.ToArray(); + message.SetLength(0); + if (data.Length > 0 && data[^1] == (byte)'\r') Array.Resize(ref data, data.Length - 1); + if (data.Length == 0) continue; + await _server.HandleMessageAsync(connection, data, cancellationToken).ConfigureAwait(false); + if (connection.IsClosed) return; + } + else + { + message.WriteByte(value); + if (message.Length > ProtocolConstants.MaxMessageBytes) + { + await _server.EndSessionForTransportAsync(connection, "packet_too_large", "Packet exceeded maximum line size.").ConfigureAwait(false); + return; + } + } + } + } + } + finally + { + await _server.HandleConnectionClosedAsync(connection).ConfigureAwait(false); + await connection.DisposeAsync(); + } + } + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + try { _listener?.Stop(); } catch { } + if (_acceptTask is not null) { try { await _acceptTask.ConfigureAwait(false); } catch { } } + var tasks = _clientTasks.Values.ToArray(); + if (tasks.Length > 0) { try { await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2)); } catch { } } + _shutdown.Dispose(); + } +} diff --git a/server/TransportAbstractions.cs b/server/TransportAbstractions.cs new file mode 100644 index 0000000..c06021a --- /dev/null +++ b/server/TransportAbstractions.cs @@ -0,0 +1,25 @@ +using System.Net; + +namespace CommonwealthOnline.Server; + +internal enum SendOutcome { Sent, Dropped, Backpressure, NotConnected, TooLarge, Error } + +internal interface IGameConnection : IAsyncDisposable +{ + string ConnectionKey { get; } + string TransportName { get; } + IPEndPoint RemoteEndpoint { get; } + bool IsClosed { get; } + ValueTask SendAsync(EncodedPacket packet, CancellationToken cancellationToken = default); + ValueTask DisconnectAsync(int reason, string debug); +} + +internal interface IServerIngress +{ + Task AcceptConnectionAsync(IGameConnection connection, CancellationToken cancellationToken); + Task HandleMessageAsync(IGameConnection connection, ReadOnlyMemory payload, CancellationToken cancellationToken); + Task HandleConnectionClosedAsync(IGameConnection connection); + Task HandleTransportRejectAsync(IGameConnection connection, string reason, bool warning = false); + Task EndSessionForTransportAsync(IGameConnection connection, string code, string reason); + void Log(string message, string level = "info"); +} diff --git a/server/admin_server.py b/server/admin_server.py deleted file mode 100644 index 134df95..0000000 --- a/server/admin_server.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -Localhost-only admin control channel for a running Commonwealth Online server. - -Binds to 127.0.0.1 and speaks newline-delimited JSON request/response messages. -Not a public game API. -""" - -from __future__ import annotations - -import json -import socket -import threading -from collections.abc import Callable -from typing import Any - - -DEFAULT_ADMIN_PORT = 7779 -ADMIN_HOST = "127.0.0.1" -ACCEPT_TIMEOUT_SECONDS = 0.5 - - -class AdminServer: - """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, - ) -> None: - self._handler = handler - self.port = port - self._log = log or (lambda _message: None) - 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 only)") - - 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 - while b"\n" in buffer: - line_bytes, buffer = buffer.split(b"\n", 1) - line = line_bytes.decode("utf-8", errors="replace").strip() - if not line: - continue - response = self._dispatch_line(line) - encoded = json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n" - connection.sendall(encoded) - except OSError as error: - self._log(f"Admin connection error from {peer}: {error}") - 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."} - - try: - response = self._handler(request) - except Exception as error: # noqa: BLE001 - admin channel must not kill the server - 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, -) -> dict[str, Any]: - """Send one admin command to a running server and return the JSON response.""" - encoded = json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n" - with socket.create_connection((host, port), timeout=timeout_seconds) as connection: - connection.sendall(encoded) - buffer = 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 - 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 7747bbb..0000000 --- a/server/ban_store.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Persistent IP ban list for Commonwealth Online servers. - -Stored as JSON next to the server config (default: bans.json). -""" - -from __future__ import annotations - -import json -import threading -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any - - -@dataclass -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: - ip = str(data.get("ip", "")).strip() - if not ip: - return None - reason = str(data.get("reason", "") or "") - 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=ip, reason=reason, banned_at=banned_at_f) - - -class BanStore: - """Thread-safe IP ban list with JSON persistence.""" - - 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 - - def load(self) -> None: - with self._lock: - if self._path is None or not self._path.exists(): - self._bans = {} - return - - try: - with open(self._path, "r", encoding="utf-8") as handle: - data = json.load(handle) - except (OSError, json.JSONDecodeError): - self._bans = {} - return - - bans: dict[str, BanEntry] = {} - raw_list = data.get("banned_ips", []) if isinstance(data, dict) else [] - if isinstance(raw_list, list): - for item in raw_list: - if not isinstance(item, dict): - continue - entry = BanEntry.from_dict(item) - if entry is not None: - bans[entry.ip] = entry - 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 e: e.ip)] - } - with open(self._path, "w", encoding="utf-8", newline="\n") as handle: - json.dump(payload, handle, indent=2, ensure_ascii=False) - handle.write("\n") - - def is_banned(self, ip: str) -> bool: - normalized = str(ip).strip() - with self._lock: - return normalized in self._bans - - def get_ban(self, ip: str) -> BanEntry | None: - normalized = str(ip).strip() - with self._lock: - return self._bans.get(normalized) - - def ban_ip(self, ip: str, reason: str = "") -> BanEntry: - normalized = str(ip).strip() - if not normalized: - raise ValueError("IP address cannot be empty.") - - entry = BanEntry( - ip=normalized, - reason=str(reason or ""), - banned_at=time.time(), - ) - with self._lock: - self._bans[normalized] = entry - self.save() - return entry - - def unban_ip(self, ip: str) -> bool: - normalized = str(ip).strip() - 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=e.ip, reason=e.reason, banned_at=e.banned_at) for e in sorted(self._bans.values(), key=lambda e: e.ip)] diff --git a/server/client_session.py b/server/client_session.py deleted file mode 100644 index cd142c0..0000000 --- a/server/client_session.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import socket -import threading -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class ClientSession: - connection: socket.socket - address: tuple[str, int] - player_id: int - connected_at: float - last_packet_at: float | None = None - last_transform: dict[str, Any] | None = None - packets_received: int = 0 - packets_sent: int = 0 - packets_broadcast: int = 0 - _lock: Any = field(repr=False, compare=False, default_factory=threading.RLock) - - @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]) -> None: - with self._lock: - self.last_transform = dict(packet) - - def record_sent(self, *, broadcast: bool = False) -> None: - with self._lock: - self.packets_sent += 1 - if broadcast: - self.packets_broadcast += 1 - - 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, - } 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/commonwealth-server.json b/server/commonwealth-server.json index f74b84d..6c72be1 100644 --- a/server/commonwealth-server.json +++ b/server/commonwealth-server.json @@ -5,5 +5,7 @@ "server_description": "Friendly co-op relay", "max_players": 16, "log_verbosity": "info", - "admin_port": 7779 + "admin_port": 7779, + "enable_gns_transport": false, + "gns_bridge_path": null } diff --git a/server/config.py b/server/config.py deleted file mode 100644 index 74cc636..0000000 --- a/server/config.py +++ /dev/null @@ -1,212 +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 - - -@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 - - 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, - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> Config: - """Create config from dictionary.""" - try: - port = int(data.get("port", 7777)) - max_players = int(data.get("max_players", 16)) - admin_port = int(data.get("admin_port", DEFAULT_ADMIN_PORT)) - except (TypeError, ValueError) as error: - raise ValueError(f"Invalid numeric config field: {error}") from error - - return cls( - host=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, - ) - - -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 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" - ) - - 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/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/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/native_transport/CMakeLists.txt b/server/native_transport/CMakeLists.txt new file mode 100644 index 0000000..42b11c0 --- /dev/null +++ b/server/native_transport/CMakeLists.txt @@ -0,0 +1,62 @@ +cmake_minimum_required(VERSION 3.20) +project(CommonwealthOnlineGnsServerBridge LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +set(GNS_SOURCE_DIR "" CACHE PATH "Path to the pinned GameNetworkingSockets source tree") +if(NOT EXISTS "${GNS_SOURCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR "GNS_SOURCE_DIR must point to an initialized GameNetworkingSockets source tree") +endif() + +set(BUILD_STATIC_LIB OFF CACHE BOOL "" FORCE) +set(BUILD_SHARED_LIB ON CACHE BOOL "" FORCE) +set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ENABLE_ICE OFF CACHE BOOL "" FORCE) +set(USE_STEAMWEBRTC OFF CACHE BOOL "" FORCE) +set(USE_CRYPTO OpenSSL CACHE STRING "" FORCE) +set(USE_CRYPTO25519 OpenSSL CACHE STRING "" FORCE) + +add_subdirectory("${GNS_SOURCE_DIR}" "${CMAKE_BINARY_DIR}/gns") + +add_library(commonwealth_online_gns_bridge SHARED + co_gns_server_bridge.cpp + co_gns_server_endpoint.cpp +) +target_include_directories(commonwealth_online_gns_bridge PUBLIC "${CMAKE_CURRENT_LIST_DIR}") +target_compile_definitions(commonwealth_online_gns_bridge PRIVATE CO_GNS_BRIDGE_BUILD) +# steamnetworkingtypes.h declares several address helpers inline, while the +# standalone definitions live in isteamnetworkingutils.h. Make that public GNS +# utility header explicit for this translation unit instead of suppressing the +# undefined-inline diagnostic. +target_compile_options(commonwealth_online_gns_bridge PRIVATE + -Wall + -Wextra + -Werror + -include + steam/isteamnetworkingutils.h +) +target_link_libraries(commonwealth_online_gns_bridge PRIVATE GameNetworkingSockets::shared) +set_target_properties(commonwealth_online_gns_bridge PROPERTIES OUTPUT_NAME "commonwealth_online_gns_bridge") + +add_executable(co-gns-server-bridge-tests test_co_gns_server_bridge.cpp) +target_compile_options(co-gns-server-bridge-tests PRIVATE -Wall -Wextra -Werror -UNDEBUG) +target_link_libraries(co-gns-server-bridge-tests PRIVATE + commonwealth_online_gns_bridge + GameNetworkingSockets::shared +) + +add_executable(co-gns-server-endpoint-tests test_co_gns_server_endpoint.cpp) +target_compile_options(co-gns-server-endpoint-tests PRIVATE -Wall -Wextra -Werror -UNDEBUG) +target_link_libraries(co-gns-server-endpoint-tests PRIVATE + commonwealth_online_gns_bridge + GameNetworkingSockets::shared +) + +enable_testing() +add_test(NAME co-gns-server-bridge COMMAND co-gns-server-bridge-tests) +add_test(NAME co-gns-server-endpoint COMMAND co-gns-server-endpoint-tests) diff --git a/server/native_transport/co_gns_server_bridge.cpp b/server/native_transport/co_gns_server_bridge.cpp new file mode 100644 index 0000000..d214d5a --- /dev/null +++ b/server/native_transport/co_gns_server_bridge.cpp @@ -0,0 +1,570 @@ +#include "co_gns_server_bridge.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr std::size_t kMaximumMessageBytes = 64uz * 1024uz; + constexpr std::size_t kReceiveBatchSize = 64; + + std::mutex g_runtimeMutex; + std::size_t g_runtimeReferenceCount = 0; + + class ServerBridge; + + std::mutex g_ownerMutex; + std::unordered_map g_listenOwners; + std::unordered_map g_connectionOwners; + + void WriteError(char* buffer, std::size_t bufferSize, const std::string& message) + { + if (buffer == nullptr || bufferSize == 0) { + return; + } + std::snprintf(buffer, bufferSize, "%s", message.c_str()); + } + + bool AcquireRuntime(std::string& error) + { + const std::scoped_lock lock(g_runtimeMutex); + if (g_runtimeReferenceCount > 0) { + ++g_runtimeReferenceCount; + return true; + } + + SteamNetworkingErrMsg errorMessage{}; + if (!GameNetworkingSockets_Init(nullptr, errorMessage)) { + error = errorMessage[0] ? std::string{ errorMessage } : "GameNetworkingSockets_Init failed."; + return false; + } + g_runtimeReferenceCount = 1; + return true; + } + + void ReleaseRuntime() + { + const std::scoped_lock lock(g_runtimeMutex); + if (g_runtimeReferenceCount == 0) { + return; + } + --g_runtimeReferenceCount; + if (g_runtimeReferenceCount == 0) { + GameNetworkingSockets_Kill(); + } + } + + struct QueuedEvent + { + co_gns_event metadata{}; + std::vector payload; + }; + + class ServerBridge + { + public: + ~ServerBridge() + { + Stop(); + } + + bool Start(const char* bindHost, std::uint16_t port, std::string& error) + { + if (!AcquireRuntime(error)) { + return false; + } + runtimeHeld_ = true; + networking_ = SteamNetworkingSockets(); + if (networking_ == nullptr) { + error = "SteamNetworkingSockets returned no server interface."; + Stop(); + return false; + } + + pollGroup_ = networking_->CreatePollGroup(); + if (pollGroup_ == k_HSteamNetPollGroup_Invalid) { + error = "GameNetworkingSockets failed to create a server poll group."; + Stop(); + return false; + } + + SteamNetworkingIPAddr address{}; + address.Clear(); + const std::string host = bindHost != nullptr ? std::string{ bindHost } : std::string{}; + if (host.empty() || host == "0.0.0.0") { + address.SetIPv4(0U, port); + } else { + if (!address.ParseString(host.c_str()) || !address.IsIPv4()) { + error = "GNS bind address must be a valid IPv4 address."; + Stop(); + return false; + } + address.m_port = port; + } + + SteamNetworkingConfigValue_t option{}; + option.SetPtr( + k_ESteamNetworkingConfig_Callback_ConnectionStatusChanged, + reinterpret_cast(+ConnectionStatusChanged)); + listenSocket_ = networking_->CreateListenSocketIP(address, 1, std::addressof(option)); + if (listenSocket_ == k_HSteamListenSocket_Invalid) { + error = "GameNetworkingSockets failed to create a listen socket."; + Stop(); + return false; + } + + { + const std::scoped_lock ownerLock(g_ownerMutex); + g_listenOwners[listenSocket_] = this; + } + + SteamNetworkingIPAddr actualAddress{}; + if (!networking_->GetListenSocketAddress(listenSocket_, std::addressof(actualAddress))) { + error = "GameNetworkingSockets could not report the bound listen address."; + Stop(); + return false; + } + localPort_ = actualAddress.m_port; + return true; + } + + void Stop() + { + ISteamNetworkingSockets* networking = networking_; + HSteamListenSocket listenSocket = listenSocket_; + HSteamNetPollGroup pollGroup = pollGroup_; + std::vector connections; + { + const std::scoped_lock lock(mutex_); + connections.assign(connections_.begin(), connections_.end()); + connections_.clear(); + connectedConnections_.clear(); + events_.clear(); + listenSocket_ = k_HSteamListenSocket_Invalid; + pollGroup_ = k_HSteamNetPollGroup_Invalid; + networking_ = nullptr; + localPort_ = 0; + } + + { + const std::scoped_lock ownerLock(g_ownerMutex); + if (listenSocket != k_HSteamListenSocket_Invalid) { + g_listenOwners.erase(listenSocket); + } + for (const auto connection : connections) { + g_connectionOwners.erase(connection); + } + } + + if (networking != nullptr) { + for (const auto connection : connections) { + networking->CloseConnection(connection, 0, nullptr, false); + } + if (listenSocket != k_HSteamListenSocket_Invalid) { + networking->CloseListenSocket(listenSocket); + } + if (pollGroup != k_HSteamNetPollGroup_Invalid) { + networking->DestroyPollGroup(pollGroup); + } + } + + if (runtimeHeld_) { + runtimeHeld_ = false; + ReleaseRuntime(); + } + } + + std::uint16_t LocalPort() const + { + const std::scoped_lock lock(mutex_); + return localPort_; + } + + std::uint32_t ConnectionCount() const + { + const std::scoped_lock lock(mutex_); + return static_cast(connectedConnections_.size()); + } + + int Poll(co_gns_event& outEvent, void* payloadBuffer, std::uint32_t payloadCapacity) + { + ISteamNetworkingSockets* networking = nullptr; + { + const std::scoped_lock lock(mutex_); + networking = networking_; + } + if (networking == nullptr) { + return -1; + } + + networking->RunCallbacks(); + + bool queueIsEmpty = false; + { + const std::scoped_lock lock(mutex_); + queueIsEmpty = events_.empty(); + } + if (queueIsEmpty) { + PumpMessages(); + } + + const std::scoped_lock lock(mutex_); + if (events_.empty()) { + std::memset(std::addressof(outEvent), 0, sizeof(outEvent)); + return 0; + } + + const auto& next = events_.front(); + outEvent = next.metadata; + if (outEvent.type == CO_GNS_EVENT_MESSAGE && next.payload.size() > payloadCapacity) { + return -2; + } + if (outEvent.type == CO_GNS_EVENT_MESSAGE && !next.payload.empty()) { + if (payloadBuffer == nullptr) { + return -2; + } + std::memcpy(payloadBuffer, next.payload.data(), next.payload.size()); + } + events_.pop_front(); + return 1; + } + + int Send( + std::uint32_t connectionId, + const void* payload, + std::uint32_t payloadSize, + std::uint32_t delivery) + { + if (payload == nullptr || payloadSize == 0) { + return CO_GNS_SEND_ERROR; + } + if (payloadSize > kMaximumMessageBytes) { + return CO_GNS_SEND_TOO_LARGE; + } + if (delivery != CO_GNS_DELIVERY_UNRELIABLE_SEQUENCED && delivery != CO_GNS_DELIVERY_RELIABLE_ORDERED) { + return CO_GNS_SEND_ERROR; + } + + const auto connection = static_cast(connectionId); + ISteamNetworkingSockets* networking = nullptr; + { + const std::scoped_lock lock(mutex_); + if (!connectedConnections_.contains(connection)) { + return CO_GNS_SEND_NOT_CONNECTED; + } + networking = networking_; + } + if (networking == nullptr) { + return CO_GNS_SEND_NOT_CONNECTED; + } + + const int flags = delivery == CO_GNS_DELIVERY_UNRELIABLE_SEQUENCED ? + k_nSteamNetworkingSend_UnreliableNoDelay : + k_nSteamNetworkingSend_ReliableNoNagle; + const auto result = networking->SendMessageToConnection( + connection, + payload, + payloadSize, + flags, + nullptr); + switch (result) { + case k_EResultOK: + return CO_GNS_SEND_SENT; + case k_EResultIgnored: + return CO_GNS_SEND_DROPPED; + case k_EResultLimitExceeded: + return CO_GNS_SEND_BACKPRESSURE; + case k_EResultNoConnection: + case k_EResultInvalidState: + return CO_GNS_SEND_NOT_CONNECTED; + default: + return CO_GNS_SEND_ERROR; + } + } + + int Disconnect(std::uint32_t connectionId, std::int32_t reason, const char* debug) + { + const auto connection = static_cast(connectionId); + ISteamNetworkingSockets* networking = nullptr; + { + const std::scoped_lock lock(mutex_); + if (!connections_.contains(connection)) { + return 0; + } + connections_.erase(connection); + connectedConnections_.erase(connection); + networking = networking_; + } + { + const std::scoped_lock ownerLock(g_ownerMutex); + g_connectionOwners.erase(connection); + } + if (networking == nullptr) { + return 0; + } + return networking->CloseConnection(connection, reason, debug, false) ? 1 : 0; + } + + private: + static ServerBridge* FindOwner(const SteamNetConnectionStatusChangedCallback_t& info) + { + const std::scoped_lock ownerLock(g_ownerMutex); + const auto connectionOwner = g_connectionOwners.find(info.m_hConn); + if (connectionOwner != g_connectionOwners.end()) { + return connectionOwner->second; + } + const auto listenOwner = g_listenOwners.find(info.m_info.m_hListenSocket); + return listenOwner == g_listenOwners.end() ? nullptr : listenOwner->second; + } + + static void ConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* info) + { + if (info == nullptr) { + return; + } + if (auto* owner = FindOwner(*info); owner != nullptr) { + owner->OnConnectionStatusChanged(*info); + } + } + + static co_gns_event MakeEvent( + std::uint32_t type, + HSteamNetConnection connection, + std::int32_t reason, + std::uint32_t payloadSize, + const char* debug) + { + co_gns_event event{}; + event.type = type; + event.connection_id = static_cast(connection); + event.reason = reason; + event.payload_size = payloadSize; + if (debug != nullptr && debug[0] != '\0') { + std::snprintf(event.debug, sizeof(event.debug), "%s", debug); + } + return event; + } + + void OnConnectionStatusChanged(const SteamNetConnectionStatusChangedCallback_t& info) + { + if (info.m_info.m_eState == k_ESteamNetworkingConnectionState_Connecting && + info.m_info.m_hListenSocket == listenSocket_) { + if (networking_->AcceptConnection(info.m_hConn) != k_EResultOK || + !networking_->SetConnectionPollGroup(info.m_hConn, pollGroup_)) { + networking_->CloseConnection(info.m_hConn, 0, "Could not admit GNS connection", false); + return; + } + { + const std::scoped_lock lock(mutex_); + connections_.insert(info.m_hConn); + } + { + const std::scoped_lock ownerLock(g_ownerMutex); + g_connectionOwners[info.m_hConn] = this; + } + return; + } + + if (info.m_info.m_eState == k_ESteamNetworkingConnectionState_Connected) { + const std::scoped_lock lock(mutex_); + if (connections_.contains(info.m_hConn) && connectedConnections_.insert(info.m_hConn).second) { + events_.push_back(QueuedEvent{ + MakeEvent(CO_GNS_EVENT_CONNECTED, info.m_hConn, 0, 0, nullptr), + {} + }); + } + return; + } + + if (info.m_info.m_eState != k_ESteamNetworkingConnectionState_ClosedByPeer && + info.m_info.m_eState != k_ESteamNetworkingConnectionState_ProblemDetectedLocally) { + return; + } + + bool knownConnection = false; + { + const std::scoped_lock lock(mutex_); + knownConnection = connections_.erase(info.m_hConn) > 0; + connectedConnections_.erase(info.m_hConn); + if (knownConnection) { + events_.push_back(QueuedEvent{ + MakeEvent( + CO_GNS_EVENT_DISCONNECTED, + info.m_hConn, + info.m_info.m_eEndReason, + 0, + info.m_info.m_szEndDebug), + {} + }); + } + } + if (!knownConnection) { + return; + } + { + const std::scoped_lock ownerLock(g_ownerMutex); + g_connectionOwners.erase(info.m_hConn); + } + networking_->CloseConnection(info.m_hConn, 0, nullptr, false); + } + + void PumpMessages() + { + ISteamNetworkingSockets* networking = nullptr; + HSteamNetPollGroup pollGroup = k_HSteamNetPollGroup_Invalid; + { + const std::scoped_lock lock(mutex_); + networking = networking_; + pollGroup = pollGroup_; + } + if (networking == nullptr || pollGroup == k_HSteamNetPollGroup_Invalid) { + return; + } + + std::array messages{}; + const auto count = networking->ReceiveMessagesOnPollGroup( + pollGroup, + messages.data(), + static_cast(messages.size())); + if (count <= 0) { + return; + } + + for (int index = 0; index < count; ++index) { + auto* message = messages[static_cast(index)]; + if (message == nullptr) { + continue; + } + const auto size = message->m_cbSize > 0 ? static_cast(message->m_cbSize) : 0uz; + const auto connection = message->m_conn; + QueuedEvent queued{}; + if (size > kMaximumMessageBytes) { + queued.metadata = MakeEvent( + CO_GNS_EVENT_OVERSIZE_MESSAGE, + connection, + 0, + static_cast((std::min)(size, static_cast(UINT32_MAX))), + "Inbound GNS message exceeded 64 KiB"); + } else { + queued.metadata = MakeEvent( + CO_GNS_EVENT_MESSAGE, + connection, + 0, + static_cast(size), + nullptr); + if (size > 0 && message->m_pData != nullptr) { + const auto* begin = static_cast(message->m_pData); + queued.payload.assign(begin, begin + size); + } + } + { + const std::scoped_lock lock(mutex_); + if (connections_.contains(connection)) { + events_.push_back(std::move(queued)); + } + } + message->Release(); + } + } + + mutable std::mutex mutex_; + ISteamNetworkingSockets* networking_{ nullptr }; + HSteamListenSocket listenSocket_{ k_HSteamListenSocket_Invalid }; + HSteamNetPollGroup pollGroup_{ k_HSteamNetPollGroup_Invalid }; + std::unordered_set connections_; + std::unordered_set connectedConnections_; + std::deque events_; + std::uint16_t localPort_{ 0 }; + bool runtimeHeld_{ false }; + }; +} + +extern "C" +{ + int co_gns_server_create( + const char* bind_host, + uint16_t port, + co_gns_server_handle* out_handle, + char* error_buffer, + size_t error_buffer_size) + { + if (out_handle == nullptr) { + WriteError(error_buffer, error_buffer_size, "out_handle is required."); + return 0; + } + *out_handle = nullptr; + auto* server = new ServerBridge(); + std::string error; + if (!server->Start(bind_host, port, error)) { + delete server; + WriteError(error_buffer, error_buffer_size, error); + return 0; + } + *out_handle = server; + WriteError(error_buffer, error_buffer_size, ""); + return 1; + } + + void co_gns_server_destroy(co_gns_server_handle handle) + { + delete static_cast(handle); + } + + uint16_t co_gns_server_local_port(co_gns_server_handle handle) + { + const auto* server = static_cast(handle); + return server == nullptr ? 0 : server->LocalPort(); + } + + uint32_t co_gns_server_connection_count(co_gns_server_handle handle) + { + const auto* server = static_cast(handle); + return server == nullptr ? 0 : server->ConnectionCount(); + } + + int co_gns_server_poll( + co_gns_server_handle handle, + co_gns_event* out_event, + void* payload_buffer, + uint32_t payload_capacity) + { + auto* server = static_cast(handle); + if (server == nullptr || out_event == nullptr) { + return -1; + } + return server->Poll(*out_event, payload_buffer, payload_capacity); + } + + int co_gns_server_send( + co_gns_server_handle handle, + uint32_t connection_id, + const void* payload, + uint32_t payload_size, + uint32_t delivery) + { + auto* server = static_cast(handle); + return server == nullptr ? CO_GNS_SEND_ERROR : + server->Send(connection_id, payload, payload_size, delivery); + } + + int co_gns_server_disconnect( + co_gns_server_handle handle, + uint32_t connection_id, + int32_t reason, + const char* debug) + { + auto* server = static_cast(handle); + return server == nullptr ? 0 : server->Disconnect(connection_id, reason, debug); + } +} diff --git a/server/native_transport/co_gns_server_bridge.h b/server/native_transport/co_gns_server_bridge.h new file mode 100644 index 0000000..35ff570 --- /dev/null +++ b/server/native_transport/co_gns_server_bridge.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include + +#if defined(_WIN32) +#if defined(CO_GNS_BRIDGE_BUILD) +#define CO_GNS_API __declspec(dllexport) +#else +#define CO_GNS_API __declspec(dllimport) +#endif +#else +#define CO_GNS_API __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void* co_gns_server_handle; + +enum co_gns_event_type +{ + CO_GNS_EVENT_NONE = 0, + CO_GNS_EVENT_CONNECTED = 1, + CO_GNS_EVENT_DISCONNECTED = 2, + CO_GNS_EVENT_MESSAGE = 3, + CO_GNS_EVENT_OVERSIZE_MESSAGE = 4 +}; + +enum co_gns_delivery +{ + CO_GNS_DELIVERY_UNRELIABLE_SEQUENCED = 0, + CO_GNS_DELIVERY_RELIABLE_ORDERED = 1 +}; + +enum co_gns_send_result +{ + CO_GNS_SEND_ERROR = -1, + CO_GNS_SEND_SENT = 0, + CO_GNS_SEND_DROPPED = 1, + CO_GNS_SEND_BACKPRESSURE = 2, + CO_GNS_SEND_NOT_CONNECTED = 3, + CO_GNS_SEND_TOO_LARGE = 4 +}; + +typedef struct co_gns_event +{ + uint32_t type; + uint32_t connection_id; + int32_t reason; + uint32_t payload_size; + char debug[128]; +} co_gns_event; + +CO_GNS_API int co_gns_server_create( + const char* bind_host, + uint16_t port, + co_gns_server_handle* out_handle, + char* error_buffer, + size_t error_buffer_size); + +CO_GNS_API void co_gns_server_destroy(co_gns_server_handle handle); +CO_GNS_API uint16_t co_gns_server_local_port(co_gns_server_handle handle); +CO_GNS_API uint32_t co_gns_server_connection_count(co_gns_server_handle handle); + +CO_GNS_API int co_gns_server_poll( + co_gns_server_handle handle, + co_gns_event* out_event, + void* payload_buffer, + uint32_t payload_capacity); + +CO_GNS_API int co_gns_server_send( + co_gns_server_handle handle, + uint32_t connection_id, + const void* payload, + uint32_t payload_size, + uint32_t delivery); + +CO_GNS_API int co_gns_server_disconnect( + co_gns_server_handle handle, + uint32_t connection_id, + int32_t reason, + const char* debug); + +CO_GNS_API int co_gns_server_remote_ipv4( + co_gns_server_handle handle, + uint32_t connection_id, + uint32_t* out_ipv4_host_order, + uint16_t* out_port); + +#ifdef __cplusplus +} +#endif diff --git a/server/native_transport/co_gns_server_endpoint.cpp b/server/native_transport/co_gns_server_endpoint.cpp new file mode 100644 index 0000000..5428ee0 --- /dev/null +++ b/server/native_transport/co_gns_server_endpoint.cpp @@ -0,0 +1,34 @@ +#include "co_gns_server_bridge.h" + +#include + +extern "C" +{ + int co_gns_server_remote_ipv4( + co_gns_server_handle handle, + uint32_t connection_id, + uint32_t* out_ipv4_host_order, + uint16_t* out_port) + { + if (handle == nullptr || connection_id == 0 || out_ipv4_host_order == nullptr || out_port == nullptr) { + return 0; + } + + auto* networking = SteamNetworkingSockets(); + if (networking == nullptr) { + return 0; + } + + SteamNetConnectionInfo_t info{}; + if (!networking->GetConnectionInfo(static_cast(connection_id), &info)) { + return 0; + } + if (!info.m_addrRemote.IsIPv4()) { + return 0; + } + + *out_ipv4_host_order = info.m_addrRemote.GetIPv4(); + *out_port = info.m_addrRemote.m_port; + return 1; + } +} diff --git a/server/native_transport/test_co_gns_server_bridge.cpp b/server/native_transport/test_co_gns_server_bridge.cpp new file mode 100644 index 0000000..301c8ca --- /dev/null +++ b/server/native_transport/test_co_gns_server_bridge.cpp @@ -0,0 +1,223 @@ +#include "co_gns_server_bridge.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + using namespace std::chrono_literals; + + HSteamNetConnection g_clientConnection = k_HSteamNetConnection_Invalid; + bool g_clientConnected = false; + bool g_clientFailed = false; + + void ClientConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* info) + { + if (info == nullptr || info->m_hConn != g_clientConnection) { + return; + } + switch (info->m_info.m_eState) { + case k_ESteamNetworkingConnectionState_Connected: + g_clientConnected = true; + break; + case k_ESteamNetworkingConnectionState_ClosedByPeer: + case k_ESteamNetworkingConnectionState_ProblemDetectedLocally: + g_clientFailed = info->m_info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally; + if (auto* networking = SteamNetworkingSockets(); networking != nullptr) { + networking->CloseConnection(info->m_hConn, 0, nullptr, false); + } + g_clientConnection = k_HSteamNetConnection_Invalid; + g_clientConnected = false; + break; + default: + break; + } + } + + struct PolledEvent + { + co_gns_event event{}; + std::string payload; + }; + + std::optional PollBridge(co_gns_server_handle server) + { + std::array payload{}; + co_gns_event event{}; + const auto result = co_gns_server_poll( + server, + &event, + payload.data(), + static_cast(payload.size())); + assert(result >= 0); + if (result == 0) { + return std::nullopt; + } + PolledEvent out{}; + out.event = event; + if (event.type == CO_GNS_EVENT_MESSAGE) { + out.payload.assign(payload.data(), event.payload_size); + } + return out; + } + + template + bool WaitUntil(co_gns_server_handle server, Predicate&& predicate, std::chrono::milliseconds timeout = 3s) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + PollBridge(server); + if (predicate()) { + return true; + } + std::this_thread::sleep_for(2ms); + } + return false; + } + + std::optional WaitForEvent( + co_gns_server_handle server, + std::uint32_t eventType, + std::chrono::milliseconds timeout = 3s) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + auto event = PollBridge(server); + if (event && event->event.type == eventType) { + return event; + } + std::this_thread::sleep_for(2ms); + } + return std::nullopt; + } + + std::optional WaitForClientMessage( + co_gns_server_handle server, + ISteamNetworkingSockets* networking, + std::chrono::milliseconds timeout = 3s) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + PollBridge(server); + ISteamNetworkingMessage* message = nullptr; + const auto count = networking->ReceiveMessagesOnConnection(g_clientConnection, &message, 1); + if (count > 0 && message != nullptr) { + std::string payload; + if (message->m_cbSize > 0 && message->m_pData != nullptr) { + payload.assign( + static_cast(message->m_pData), + static_cast(message->m_cbSize)); + } + message->Release(); + return payload; + } + assert(count >= 0); + std::this_thread::sleep_for(2ms); + } + return std::nullopt; + } +} + +int main() +{ + co_gns_server_handle server = nullptr; + std::array error{}; + const int createResult = co_gns_server_create("127.0.0.1", 0, &server, error.data(), error.size()); + if (createResult != 1) { + // No usable loopback socket in this environment (e.g. a network-isolated + // CI sandbox). Skip rather than fail; the bridge is exercised for real + // wherever a UDP socket can be created. + std::fprintf(stderr, "SKIP: environment cannot create a GNS listen socket: %s\n", error.data()); + return 0; + } + assert(server != nullptr); + const auto port = co_gns_server_local_port(server); + assert(port != 0); + + auto* networking = SteamNetworkingSockets(); + assert(networking != nullptr); + SteamNetworkingIPAddr serverAddress{}; + serverAddress.Clear(); + serverAddress.SetIPv4(0x7f000001U, port); + SteamNetworkingConfigValue_t option{}; + option.SetPtr( + k_ESteamNetworkingConfig_Callback_ConnectionStatusChanged, + reinterpret_cast(+ClientConnectionStatusChanged)); + g_clientConnection = networking->ConnectByIPAddress(serverAddress, 1, &option); + assert(g_clientConnection != k_HSteamNetConnection_Invalid); + + const auto connectedEvent = WaitForEvent(server, CO_GNS_EVENT_CONNECTED); + assert(connectedEvent.has_value()); + const auto serverConnectionId = connectedEvent->event.connection_id; + assert(serverConnectionId != 0); + assert(WaitUntil(server, []() { return g_clientConnected && !g_clientFailed; })); + assert(co_gns_server_connection_count(server) == 1); + + const std::string reliable = R"({"type":"playerState","characterName":"Nomad"})"; + assert(networking->SendMessageToConnection( + g_clientConnection, + reliable.data(), + static_cast(reliable.size()), + k_nSteamNetworkingSend_ReliableNoNagle, + nullptr) == k_EResultOK); + const auto messageEvent = WaitForEvent(server, CO_GNS_EVENT_MESSAGE); + assert(messageEvent.has_value()); + assert(messageEvent->event.connection_id == serverConnectionId); + assert(messageEvent->payload == reliable); + assert(messageEvent->payload.find('\n') == std::string::npos); + + const std::string reliableResponse = R"({"type":"sessionReady","playerId":1})"; + assert(co_gns_server_send( + server, + serverConnectionId, + reliableResponse.data(), + static_cast(reliableResponse.size()), + CO_GNS_DELIVERY_RELIABLE_ORDERED) == CO_GNS_SEND_SENT); + const auto clientReliable = WaitForClientMessage(server, networking); + assert(clientReliable.has_value()); + assert(*clientReliable == reliableResponse); + + const std::string snapshotResponse = R"({"type":"transform","snapshotSequence":8,"x":1})"; + assert(co_gns_server_send( + server, + serverConnectionId, + snapshotResponse.data(), + static_cast(snapshotResponse.size()), + CO_GNS_DELIVERY_UNRELIABLE_SEQUENCED) == CO_GNS_SEND_SENT); + const auto clientSnapshot = WaitForClientMessage(server, networking); + assert(clientSnapshot.has_value()); + assert(*clientSnapshot == snapshotResponse); + + const std::string tooLarge((64 * 1024) + 1, 'x'); + assert(co_gns_server_send( + server, + serverConnectionId, + tooLarge.data(), + static_cast(tooLarge.size()), + CO_GNS_DELIVERY_RELIABLE_ORDERED) == CO_GNS_SEND_TOO_LARGE); + assert(networking->SendMessageToConnection( + g_clientConnection, + tooLarge.data(), + static_cast(tooLarge.size()), + k_nSteamNetworkingSend_ReliableNoNagle, + nullptr) == k_EResultOK); + const auto oversizeEvent = WaitForEvent(server, CO_GNS_EVENT_OVERSIZE_MESSAGE); + assert(oversizeEvent.has_value()); + assert(oversizeEvent->event.connection_id == serverConnectionId); + assert(oversizeEvent->event.payload_size == tooLarge.size()); + + assert(co_gns_server_disconnect(server, serverConnectionId, 1000, "test complete") == 1); + assert(WaitUntil(server, []() { return g_clientConnection == k_HSteamNetConnection_Invalid; })); + assert(!g_clientFailed); + + co_gns_server_destroy(server); + return 0; +} diff --git a/server/native_transport/test_co_gns_server_endpoint.cpp b/server/native_transport/test_co_gns_server_endpoint.cpp new file mode 100644 index 0000000..157b093 --- /dev/null +++ b/server/native_transport/test_co_gns_server_endpoint.cpp @@ -0,0 +1,88 @@ +#include "co_gns_server_bridge.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + using namespace std::chrono_literals; + + HSteamNetConnection g_clientConnection = k_HSteamNetConnection_Invalid; + bool g_clientConnected = false; + + void ClientConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* info) + { + if (info == nullptr || info->m_hConn != g_clientConnection) { + return; + } + if (info->m_info.m_eState == k_ESteamNetworkingConnectionState_Connected) { + g_clientConnected = true; + } + } +} + +int main() +{ + co_gns_server_handle server = nullptr; + std::array error{}; + if (co_gns_server_create("127.0.0.1", 0, &server, error.data(), error.size()) != 1) { + // Skip in a network-isolated environment (see the bridge test note). + std::fprintf(stderr, "SKIP: environment cannot create a GNS listen socket: %s\n", error.data()); + return 0; + } + assert(server != nullptr); + const auto serverPort = co_gns_server_local_port(server); + assert(serverPort != 0); + + auto* networking = SteamNetworkingSockets(); + assert(networking != nullptr); + SteamNetworkingIPAddr serverAddress{}; + serverAddress.Clear(); + serverAddress.SetIPv4(0x7F000001U, serverPort); + SteamNetworkingConfigValue_t option{}; + option.SetPtr( + k_ESteamNetworkingConfig_Callback_ConnectionStatusChanged, + reinterpret_cast(+ClientConnectionStatusChanged)); + g_clientConnection = networking->ConnectByIPAddress(serverAddress, 1, &option); + assert(g_clientConnection != k_HSteamNetConnection_Invalid); + + std::uint32_t serverConnectionId = 0; + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (std::chrono::steady_clock::now() < deadline && (serverConnectionId == 0 || !g_clientConnected)) { + co_gns_event event{}; + std::array payload{}; + const auto pollResult = co_gns_server_poll( + server, + &event, + payload.data(), + static_cast(payload.size())); + assert(pollResult >= 0); + if (pollResult == 1 && event.type == CO_GNS_EVENT_CONNECTED) { + serverConnectionId = event.connection_id; + } + std::this_thread::sleep_for(2ms); + } + + assert(g_clientConnected); + assert(serverConnectionId != 0); + std::uint32_t remoteIpv4 = 0; + std::uint16_t remotePort = 0; + assert(co_gns_server_remote_ipv4(server, serverConnectionId, &remoteIpv4, &remotePort) == 1); + assert(remoteIpv4 == 0x7F000001U); + assert(remotePort != 0); + + std::uint32_t missingIpv4 = 0; + std::uint16_t missingPort = 0; + assert(co_gns_server_remote_ipv4(server, serverConnectionId + 100000U, &missingIpv4, &missingPort) == 0); + + networking->CloseConnection(g_clientConnection, 0, nullptr, false); + g_clientConnection = k_HSteamNetConnection_Invalid; + co_gns_server_destroy(server); + return 0; +} 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..6a5efd8 --- a/server/scripts/linux_compat_checks.sh +++ b/server/scripts/linux_compat_checks.sh @@ -4,37 +4,25 @@ 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 "==> Enforcing repository runtime policy" +bash scripts/verify-no-legacy-runtime.sh -echo "==> Checking start.sh is executable" -test -x start.sh - -echo "==> bash -n start.sh" +echo "==> Checking shell scripts" +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 +chmod +x start.sh fix-port.sh scripts/verify-no-legacy-runtime.sh bash -n start.sh - +bash -n fix-port.sh +bash -n scripts/verify-no-legacy-runtime.sh if command -v shellcheck >/dev/null 2>&1; then - echo "==> shellcheck start.sh" - shellcheck start.sh -else - echo "WARNING: shellcheck not installed; skipping" + shellcheck start.sh fix-port.sh scripts/verify-no-legacy-runtime.sh 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 +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 -echo "==> compileall" -.venv-ci/bin/python -m compileall -q . - -echo "==> pytest" -.venv-ci/bin/python -m pytest -q - -echo "All Linux compatibility checks passed." +echo "All C# Linux compatibility checks passed." diff --git a/server/scripts/verify-no-legacy-runtime.sh b/server/scripts/verify-no-legacy-runtime.sh new file mode 100644 index 0000000..6612ebd --- /dev/null +++ b/server/scripts/verify-no-legacy-runtime.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd -- "${ROOT}" + +SELF="server/scripts/verify-no-legacy-runtime.sh" +FAILED=0 + +echo "==> Checking tracked paths for prohibited legacy runtime artifacts" +while IFS= read -r -d '' path; do + lower="${path,,}" + if [[ "${lower}" =~ (^|/)(requirements[^/]*\.txt|pipfile(\.lock)?|pyproject\.toml|poetry\.lock|setup\.py|setup\.cfg|tox\.ini|\.python-version)$ ]] \ + || [[ "${lower}" =~ (^|/)(\.venv|venv|__pycache__|\.pytest_cache)(/|$) ]] \ + || [[ "${lower}" =~ \.(py|pyw|pyi|pyc|pyo|whl|egg)$ ]]; then + echo "ERROR: prohibited tracked path: ${path}" >&2 + FAILED=1 + fi +done < <(git ls-files -z) + +# Keep runtime/dependency references out of source, scripts, workflows, configs, +# packaging and documentation. This policy file is the sole intentional source +# of the forbidden tokens below. +FORBIDDEN='(^|[^[:alnum:]_])(python([0-9]+([.][0-9]+)*)?|py[.]exe|pip([0-9]+)?|pytest|pyside6|virtualenv|venv)([^[:alnum:]_]|$)|[.]venv|consumer_server_cli[.]py|admin_server[.]py|server_core[.]py|requirements-(server|host-gui)[.]txt' + +echo "==> Checking tracked text for prohibited legacy runtime references" +if git grep -nI -i -E "${FORBIDDEN}" -- . ":(exclude)${SELF}"; then + echo "ERROR: prohibited legacy runtime reference found in tracked content." >&2 + FAILED=1 +fi + +if [[ ${FAILED} -ne 0 ]]; then + exit 1 +fi + +echo "Legacy runtime guard passed." diff --git a/server/server.py b/server/server.py deleted file mode 100644 index bff5b6d..0000000 --- a/server/server.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from server_core import FalloutTogetherServer - - -def log(message: str) -> None: - print(message, flush=True) - - -def main() -> None: - server = FalloutTogetherServer() - server.add_log_listener(log) - - try: - server.serve_forever() - finally: - server.stop() - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - log("\nServer stopped.") diff --git a/server/server_core.py b/server/server_core.py deleted file mode 100644 index 5c964f5..0000000 --- a/server/server_core.py +++ /dev/null @@ -1,1151 +0,0 @@ -from __future__ import annotations - -import json -import math -import socket -import sys -import threading -import time -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 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" - -_LOG_LEVELS = { - "debug": 10, - "info": 20, - "warning": 30, - "error": 40, -} - - -def get_lan_addresses() -> list[str]: - """Return likely non-loopback IPv4 addresses for this machine.""" - addresses: list[str] = [] - - try: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: - probe.connect(("8.8.8.8", 80)) - 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[[str], 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._stats: dict[str, int] = { - "clientsConnected": 0, - "clientsDisconnected": 0, - "packetsReceived": 0, - "packetsSent": 0, - "packetsBroadcast": 0, - "transformPacketsReceived": 0, - "transformPacketsBroadcast": 0, - "worldStatePacketsReceived": 0, - "worldStatePacketsBroadcast": 0, - "npcStatePacketsReceived": 0, - "npcStatePacketsBroadcast": 0, - "combatHitsReceived": 0, - "combatHitsRouted": 0, - "worldStateHostPacketsBroadcast": 0, - "serverWorldStatePacketsBroadcast": 0, - "sessionEndedPacketsSent": 0, - "bannedConnectionsRejected": 0, - "disconnectPacketsBroadcast": 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: - clients: list[ClientSession] - server_socket: socket.socket | None - client_threads: list[threading.Thread] - - with self._lock: - if not self._running and self._server_socket is None and self._discovery is None: - return - self._running = False - server_socket = self._server_socket - self._server_socket = None - clients = list(self._clients.values()) - client_threads = [ - thread for thread in self._client_threads if thread is not threading.current_thread() - ] - - discovery = self._discovery - self._discovery = None - 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 get_clients(self) -> list[dict[str, Any]]: - with self._lock: - clients = list(self._clients.values()) - - return [client.to_snapshot() for client in clients] - - def get_stats(self) -> dict[str, Any]: - with self._lock: - connected_clients = len(self._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": connected_clients, - "nextPlayerId": self._next_player_id, - } - ) - - 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) - self._log( - f"Banned IP {entry.ip}" - + (f" (reason: {entry.reason})" if entry.reason else "") - ) - ended = self._end_sessions_for_ip(normalized, code=SESSION_ENDED_BANNED, reason=entry.reason) - 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._log( - f"Kicking player {player_id} ({client.label})" - + (f" (reason: {reason})" if reason else "") - ) - self._end_client_session(client, code=SESSION_ENDED_KICKED, reason=reason) - return { - "playerId": player_id, - "ip": ip, - "code": SESSION_ENDED_KICKED, - "reason": str(reason or ""), - } - - def add_log_listener(self, callback: Callable[[str], 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[[str], 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}. " - f"The port may already be in use or unavailable. ({error})" - ) from error - - self._server_socket = server_socket - self._running = True - self._started_at = time.time() - # Each server run is a fresh session for player/host assignment. - # Without this reset, restarts can leave stale host IDs and player IDs - # > 1, which breaks weather authority handoff to the active host. - self._next_player_id = 1 - self._world_state_host_player_id = None - self._last_npc_state = None - - self._log(f"Commonwealth Online server listening on {self.host}:{self.port}") - if self.host == "0.0.0.0": - self._log( - f"Bind address 0.0.0.0 means all interfaces; clients should not join 0.0.0.0." - ) - self._log(f"Local clients can connect at 127.0.0.1:{self.port}") - lan_addresses = get_lan_addresses() - for address in lan_addresses: - self._log(f"LAN clients can connect at {address}:{self.port}") - if not lan_addresses: - self._log("Could not detect a LAN IPv4 address for this machine.") - if sys.platform == "win32": - self._log( - "Forward this port on your router and allow it through Windows Firewall " - "for connections from outside your network." - ) - else: - self._log( - "Allow TCP " - f"{self.port} through your host firewall. Router port forwarding is only " - "needed for connections from outside your LAN." - ) - else: - self._log(f"Clients can connect at {self.host}:{self.port}") - self._log("Waiting for newline-separated JSON transform packets...") - - def _start_discovery(self) -> None: - discovery = LanDiscoveryResponder(self) - try: - discovery.start() - except OSError as error: - self._log( - f"LAN discovery unavailable on UDP {DISCOVERY_PORT}: {error}. " - "Direct TCP connections still work.", - level="warning", - ) - self._discovery = None - return - - self._discovery = discovery - self._log(f"LAN discovery listening on UDP port {DISCOVERY_PORT}") - - def _accept_loop(self) -> None: - try: - while self.is_running(): - with self._lock: - server_socket = self._server_socket - - if server_socket is None: - break - - try: - connection, address = server_socket.accept() - except socket.timeout: - # Wake periodically so Ctrl+C is handled promptly in Windows terminals. - continue - except OSError: - if self.is_running(): - self._log("Server socket closed unexpectedly.") - break - - 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: - with self._lock: - # The server owns player IDs so early clients do not need to coordinate - # identity with each other or know anything about remote players yet. - 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 - self._stats["clientsConnected"] += 1 - if self._world_state_host_player_id is None: - self._world_state_host_player_id = client.player_id - - return client - - def _remove_client(self, client: ClientSession) -> bool: - with self._lock: - removed = self._clients.pop(client.connection, None) is not None - if removed: - self._stats["clientsDisconnected"] += 1 - return removed - - 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._log(f"Rejected banned IP {peer_ip}:{address[1]}") - self._send_session_ended_raw( - connection, - code=SESSION_ENDED_BANNED, - reason=ban_entry.reason, - ) - try: - connection.close() - except OSError: - pass - return - - client = self._assign_client(connection, address) - self._log( - f"TCP accept: {client.label} (player {client.player_id})", - level="debug", - ) - - try: - with connection: - buffer = b"" - - try: - with self._lock: - world_state_host_player_id = self._world_state_host_player_id - - welcome_packet: dict[str, Any] = { - "type": "welcome", - "playerId": client.player_id, - "serverTime": time.time(), - "serverName": self.server_name, - } - if self.server_description: - welcome_packet["serverDescription"] = self.server_description - if world_state_host_player_id is not None: - welcome_packet["worldStateHostPlayerId"] = world_state_host_player_id - - self._send_packet(client, welcome_packet) - # Delay transform/npc snapshots until the client sends its first - # packet so TCP ping probes (connect+close) do not pull world state. - - while self.is_running(): - chunk = connection.recv(4096) - if not chunk: - break - - buffer += chunk - while b"\n" in buffer: - line_bytes, buffer = buffer.split(b"\n", 1) - line = line_bytes.decode("utf-8", errors="replace").strip() - self._handle_line(client, line) - except ConnectionResetError: - if client.packets_received > 0: - self._log( - f"Client disconnected unexpectedly: {client.label} " - f"(player {client.player_id})" - ) - else: - self._log( - f"Short-lived connection closed: {client.label} " - f"(player {client.player_id})", - level="debug", - ) - except OSError as error: - if self.is_running(): - self._log( - f"Client connection error: {client.label} " - f"(player {client.player_id}): {error}" - ) - finally: - self._disconnect_client(client) - finally: - with self._lock: - self._client_threads.discard(threading.current_thread()) - - def _handle_line(self, client: ClientSession, line: str) -> None: - if not line: - return - - received_at = time.time() - packets_received = client.record_received(received_at) - if packets_received == 1: - self._log(f"Client connected: {client.label} (player {client.player_id})") - self._send_existing_transforms_to_client(client) - self._send_existing_npc_state_to_client(client) - - with self._lock: - self._stats["packetsReceived"] += 1 - - try: - packet = json.loads(line) - except json.JSONDecodeError as error: - self._log(f"Invalid JSON from {client.label}: {error}: {line}") - return - - if not isinstance(packet, dict): - self._log(f"Invalid packet from {client.label}: expected JSON object: {packet}") - return - - if packet.get("type") == "transform": - packet["playerId"] = client.player_id - packet["serverTime"] = time.time() - client.record_transform(packet) - with self._lock: - self._stats["transformPacketsReceived"] += 1 - - if packet.get("type") == "worldState": - with self._lock: - world_state_host_player_id = self._world_state_host_player_id - - if world_state_host_player_id is None or client.player_id != world_state_host_player_id: - self._log( - f"Ignoring worldState from non-host {client.label} " - f"(player {client.player_id}, host is player {world_state_host_player_id})." - ) - return - - packet["playerId"] = client.player_id - packet["serverTime"] = time.time() - with self._lock: - self._stats["worldStatePacketsReceived"] += 1 - - if packet.get("type") == "npcState": - with self._lock: - world_state_host_player_id = self._world_state_host_player_id - - if world_state_host_player_id is None or client.player_id != world_state_host_player_id: - self._log( - f"Ignoring npcState from non-host {client.label} " - f"(player {client.player_id}, host is player {world_state_host_player_id})." - ) - return - - npcs = packet.get("npcs") - if not isinstance(npcs, list) or len(npcs) > 16 or any(not isinstance(npc, dict) for npc in npcs): - self._log(f"Ignoring malformed npcState from host player {client.player_id}.") - return - - packet["playerId"] = client.player_id - packet["serverTime"] = time.time() - packet["fullReplace"] = True - with self._lock: - self._last_npc_state = dict(packet) - self._stats["npcStatePacketsReceived"] += 1 - - if packet.get("type") == "combatHit": - packet["playerId"] = client.player_id - packet["serverTime"] = time.time() - with self._lock: - self._stats["combatHitsReceived"] += 1 - - self._print_packet(client, packet) - - if packet.get("type") == "transform": - self._broadcast_transform(client, packet) - elif packet.get("type") == "worldState": - self._broadcast_world_state(client, packet) - elif packet.get("type") == "npcState": - self._broadcast_npc_state(client, packet) - elif packet.get("type") == "combatHit": - self._route_combat_hit(client, packet) - - def _send_packet(self, client: ClientSession, packet: dict[str, Any], *, broadcast: bool = False) -> None: - encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n" - client.connection.sendall(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: - snapshots = [ - dict(client.last_transform) - for client in self._clients.values() - if client.connection != new_client.connection and client.last_transform is not None - ] - - if not snapshots: - return - - successful_sends = 0 - failed = False - for packet in snapshots: - packet["serverTime"] = time.time() - try: - self._send_packet(new_client, packet, broadcast=True) - successful_sends += 1 - except OSError: - failed = True - break - - with self._lock: - self._stats["transformPacketsBroadcast"] += successful_sends - - if successful_sends: - self._log( - f"Sent {successful_sends} existing transform snapshot(s) to newly connected player {new_client.player_id}" - ) - - if failed: - self._disconnect_client(new_client) - - def _send_existing_npc_state_to_client(self, new_client: ClientSession) -> None: - 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") == new_client.player_id: - return - - snapshot["serverTime"] = time.time() - try: - self._send_packet(new_client, snapshot, broadcast=True) - with self._lock: - self._stats["npcStatePacketsBroadcast"] += 1 - self._log( - f"Sent existing npcState snapshot with {len(snapshot.get('npcs', []))} " - f"enemy/enemies to newly connected player {new_client.player_id}" - ) - except OSError: - self._disconnect_client(new_client) - - def _disconnect_client(self, client: ClientSession) -> None: - with self._lock: - was_world_state_host = client.player_id == self._world_state_host_player_id - never_sent_packet = client.packets_received == 0 - - if not self._remove_client(client): - return - - self._close_client_socket(client) - - if never_sent_packet: - # TCP connect probes (browser/pause ping) never send gameplay packets. - # Avoid broadcasting a peer disconnect for those ghost sessions. - self._log( - f"Ignored probe/short-lived client: {client.label} (player {client.player_id})", - level="debug", - ) - if was_world_state_host: - self._reassign_world_state_host() - return - - self._log(f"Client disconnected: {client.label} (player {client.player_id})") - self._broadcast_disconnect(client) - - if was_world_state_host: - self._reassign_world_state_host() - - def _find_client_by_player_id(self, player_id: int) -> ClientSession | None: - with self._lock: - for client in self._clients.values(): - if client.player_id == player_id: - return client - return 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]: - packet: dict[str, Any] = { - "type": "sessionEnded", - "code": code, - "serverTime": time.time(), - } - reason_text = str(reason or "") - if reason_text: - packet["reason"] = reason_text - else: - packet["reason"] = "" - return packet - - 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=(",", ":")).encode("utf-8") + b"\n" - try: - connection.sendall(encoded) - with self._lock: - self._stats["sessionEndedPacketsSent"] += 1 - self._stats["packetsSent"] += 1 - try: - connection.shutdown(socket.SHUT_WR) - except OSError: - pass - except OSError: - pass - - def _end_client_session( - self, - client: ClientSession, - *, - code: str, - reason: str = "", - ) -> None: - packet = self._build_session_ended_packet(code, reason) - try: - self._send_packet(client, packet) - with self._lock: - self._stats["sessionEndedPacketsSent"] += 1 - try: - client.connection.shutdown(socket.SHUT_WR) - except OSError: - pass - except OSError: - 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_client_socket(self, client: ClientSession) -> None: - try: - client.connection.close() - except OSError: - pass - - def _broadcast_transform(self, sender: ClientSession, packet: dict[str, Any]) -> None: - with self._lock: - recipients = [client for client in self._clients.values() if client.connection != sender.connection] - - failed_recipients: list[ClientSession] = [] - successful_sends = 0 - for recipient in recipients: - try: - # Transforms go to every other client only. The sender already knows - # its own movement; echoing it back would create duplicate local state. - self._send_packet(recipient, packet, broadcast=True) - successful_sends += 1 - except OSError: - failed_recipients.append(recipient) - - with self._lock: - self._stats["transformPacketsBroadcast"] += successful_sends - - for recipient in failed_recipients: - self._disconnect_client(recipient) - - self._log( - f"Broadcast transform from player {sender.player_id} to {successful_sends} other client(s)", - level="debug", - ) - - def _broadcast_world_state(self, sender: ClientSession, packet: dict[str, Any]) -> None: - with self._lock: - recipients = [client for client in self._clients.values() if client.connection != sender.connection] - - failed_recipients: list[ClientSession] = [] - successful_sends = 0 - for recipient in recipients: - try: - self._send_packet(recipient, packet, broadcast=True) - successful_sends += 1 - except OSError: - failed_recipients.append(recipient) - - with self._lock: - self._stats["worldStatePacketsBroadcast"] += successful_sends - - for recipient in failed_recipients: - self._disconnect_client(recipient) - - game_hour = packet.get("gameHour", "?") - game_days_passed = packet.get("gameDaysPassed", "?") - weather_form_id = packet.get("weatherFormId", "") - weather_detail = f", weatherFormId={weather_form_id}" if weather_form_id else "" - self._log( - f"Broadcast worldState from player {sender.player_id} to {successful_sends} other client(s): " - f"gameHour={game_hour}, gameDaysPassed={game_days_passed}{weather_detail}", - level="debug", - ) - - def _broadcast_npc_state(self, sender: ClientSession, packet: dict[str, Any]) -> None: - with self._lock: - recipients = [client for client in self._clients.values() if client.connection != sender.connection] - - failed_recipients: list[ClientSession] = [] - successful_sends = 0 - for recipient in recipients: - try: - self._send_packet(recipient, packet, broadcast=True) - successful_sends += 1 - except OSError: - failed_recipients.append(recipient) - - with self._lock: - self._stats["npcStatePacketsBroadcast"] += successful_sends - - for recipient in failed_recipients: - self._disconnect_client(recipient) - - self._log( - f"Broadcast npcState from player {sender.player_id} to {successful_sends} " - f"other client(s): enemies={len(packet.get('npcs', []))}", - level="debug", - ) - - def _broadcast_server_world_state(self) -> None: - with self._lock: - if not self._running: - self._log("Cannot broadcast server world state while the server is stopped.") - return - - recipients = list(self._clients.values()) - snapshot = dict(self._server_world_state) - - time_hhmm = snapshot.get("timeHHmm") - weather_console_arg = snapshot.get("weatherConsoleArg") - weather_form_id = snapshot.get("weatherFormId") - if not time_hhmm and not weather_console_arg: - self._log("Server world state broadcast skipped: no time or weather set.") - return - - server_time = time.time() - failed_recipients: list[ClientSession] = [] - successful_sends = 0 - - if time_hhmm: - time_packet: dict[str, Any] = { - "type": "serverWorldState", - "timeHHmm": time_hhmm, - "serverTime": server_time, - } - for recipient in recipients: - try: - self._send_packet(recipient, time_packet, broadcast=True) - successful_sends += 1 - except OSError: - failed_recipients.append(recipient) - - self._log( - f"Broadcast serverWorldState time to {len(recipients)} client(s): timeHHmm={time_hhmm}" - ) - - if weather_console_arg: - weather_packet: dict[str, Any] = { - "type": "serverWorldState", - "weatherConsoleArg": weather_console_arg, - "serverTime": server_time, - } - if weather_form_id: - weather_packet["weatherFormId"] = weather_form_id - - for recipient in recipients: - try: - self._send_packet(recipient, weather_packet, broadcast=True) - successful_sends += 1 - except OSError: - failed_recipients.append(recipient) - - self._log( - "Broadcast serverWorldState weather command to " - f"{len(recipients)} client(s): fw {weather_console_arg}" - ) - - with self._lock: - self._stats["serverWorldStatePacketsBroadcast"] += successful_sends - - for recipient in failed_recipients: - self._disconnect_client(recipient) - - def _reassign_world_state_host(self) -> None: - with self._lock: - if not self._clients: - self._world_state_host_player_id = None - self._last_npc_state = None - self._log("World-state host cleared; no clients remain.") - return - - new_host_player_id = min(client.player_id for client in self._clients.values()) - self._world_state_host_player_id = new_host_player_id - self._last_npc_state = None - recipients = list(self._clients.values()) - - packet = { - "type": "worldStateHost", - "worldStateHostPlayerId": new_host_player_id, - "serverTime": time.time(), - } - - failed_recipients: list[ClientSession] = [] - successful_sends = 0 - for recipient in recipients: - try: - self._send_packet(recipient, packet, broadcast=True) - successful_sends += 1 - except OSError: - failed_recipients.append(recipient) - - with self._lock: - self._stats["worldStateHostPacketsBroadcast"] += successful_sends - - for recipient in failed_recipients: - self._disconnect_client(recipient) - - self._log( - f"Reassigned world-state host to player {new_host_player_id} " - f"and notified {successful_sends} client(s)." - ) - - def _broadcast_disconnect(self, disconnected_client: ClientSession) -> None: - packet = { - "type": "disconnect", - "playerId": disconnected_client.player_id, - "serverTime": time.time(), - } - - with self._lock: - recipients = list(self._clients.values()) - - failed_recipients: list[ClientSession] = [] - successful_sends = 0 - for recipient in recipients: - try: - self._send_packet(recipient, packet, broadcast=True) - successful_sends += 1 - except OSError: - failed_recipients.append(recipient) - - with self._lock: - self._stats["disconnectPacketsBroadcast"] += successful_sends - - for recipient in failed_recipients: - self._disconnect_client(recipient) - - self._log( - f"Broadcast disconnect for player {disconnected_client.player_id} " - f"to {successful_sends} other client(s)" - ) - - def _route_combat_hit(self, sender: ClientSession, packet: dict[str, Any]) -> None: - """Route a targeted combatHit packet to the victim player only.""" - target_player_id = packet.get("targetPlayerId") - sequence = packet.get("sequence") - damage_value = packet.get("damage") - - # Coerce numeric JSON values: some clients/serializers emit floats for integers. - if isinstance(target_player_id, float) and target_player_id.is_integer(): - target_player_id = int(target_player_id) - packet["targetPlayerId"] = target_player_id - if isinstance(sequence, float) and sequence.is_integer(): - sequence = int(sequence) - packet["sequence"] = sequence - - if ( - not isinstance(target_player_id, int) - or isinstance(target_player_id, bool) - or not isinstance(sequence, int) - or isinstance(sequence, bool) - or not isinstance(damage_value, (int, float)) - or isinstance(damage_value, bool) - ): - self._log(f"Malformed combatHit packet from player {sender.player_id}: {packet}") - return - - damage = float(damage_value) - if ( - not 0 < target_player_id <= 0xFFFFFFFF - or not 0 < sequence <= 0xFFFFFFFF - or not math.isfinite(damage) - or not 0.0 < damage <= 10000.0 - ): - self._log( - f"Invalid combatHit values from player {sender.player_id}: " - f"targetPlayerId={target_player_id}, sequence={sequence}, damage={damage}" - ) - return - - if target_player_id == sender.player_id: - self._log(f"Ignoring self-targeted combatHit from player {sender.player_id}") - return - - weapon_form_id = packet.get("weaponFormId") - if weapon_form_id is not None: - if ( - not isinstance(weapon_form_id, str) - or not 1 <= len(weapon_form_id) <= 8 - or any(character not in "0123456789abcdefABCDEF" for character in weapon_form_id) - ): - self._log( - f"Invalid combatHit weaponFormId from player {sender.player_id}: " - f"{weapon_form_id!r}" - ) - return - packet["weaponFormId"] = weapon_form_id.upper().zfill(8) - - with self._lock: - recipient = next( - (client for client in self._clients.values() if client.player_id == target_player_id), - None, - ) - - if recipient is None: - self._log( - f"Target player {target_player_id} not found for combatHit from player {sender.player_id}" - ) - return - - try: - self._send_packet(recipient, packet, broadcast=False) - with self._lock: - self._stats["combatHitsRouted"] += 1 - self._log( - f"Routed combatHit from player {sender.player_id} to player {target_player_id}: " - f"damage={damage:.1f}" - ) - except OSError: - self._disconnect_client(recipient) - - def _print_packet(self, client: ClientSession, packet: dict[str, Any]) -> None: - packet_type = packet.get("type") - if packet_type == "worldState": - game_hour = packet.get("gameHour", "?") - game_days_passed = packet.get("gameDaysPassed", "?") - weather_form_id = packet.get("weatherFormId", "") - extra_fields = [] - for field_name in ("playerId", "clientTime", "serverTime"): - if field_name in packet: - extra_fields.append(f"{field_name}={packet[field_name]}") - extra_details = "" - if extra_fields: - extra_details = ", " + ", ".join(extra_fields) - weather_detail = f", weatherFormId={weather_form_id}" if weather_form_id else "" - self._log( - f"WorldState from {client.label}: gameHour={game_hour}, " - f"gameDaysPassed={game_days_passed}{weather_detail}{extra_details}", - level="debug", - ) - return - - if packet_type != "transform": - self._log(f"Packet from {client.label}: {packet}", level="debug") - return - - try: - x = float(packet["x"]) - y = float(packet["y"]) - z = float(packet["z"]) - angle_z = float(packet["angleZ"]) - except (KeyError, TypeError, ValueError): - self._log(f"Malformed transform packet from {client.label}: {packet}", level="warning") - return - - movement_type = packet.get("movementType", "normal") - extra_fields = [] - for field_name in ("playerId", "cellId", "worldspaceId", "clientTime", "serverTime"): - if field_name in packet: - extra_fields.append(f"{field_name}={packet[field_name]}") - - extra_details = "" - if extra_fields: - extra_details = ", " + ", ".join(extra_fields) - - self._log( - "Transform from " - f"{client.label}: x={x:.2f}, y={y:.2f}, z={z:.2f}, angleZ={angle_z:.2f}, " - f"movementType={movement_type}{extra_details}", - level="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) - - for listener in listeners: - try: - # Prefer level-aware callbacks; fall back for older listeners. - try: - listener(message, level=level) # type: ignore[call-arg] - except TypeError: - listener(message) - except Exception: - # Future UI listeners must not be able to break server networking. - pass diff --git a/server/server_service.py b/server/server_service.py deleted file mode 100644 index 2d26662..0000000 --- a/server/server_service.py +++ /dev/null @@ -1,443 +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 server_core import FalloutTogetherServer - - -_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 - - -@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._log_listeners: list[Callable[..., None]] = [] - self._log_lock = threading.RLock() - self._serve_thread: threading.Thread | None = None - self._running = False - self._stop_requested = False - - def add_log_listener(self, callback: Callable[..., 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 FalloutTogetherServer( - 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(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._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._server = self._create_server() - self._server.add_log_listener(self._dispatch_log) - self._running = True - self._start_admin() - - try: - self._server.serve_forever() - finally: - self._stop_admin() - self._running = False - - def _serve_forever(self) -> None: - """Internal serve_forever for background thread.""" - try: - if self._server: - self._server.serve_forever() - finally: - self._stop_admin() - self._running = False - - def stop(self) -> None: - """Stop the server.""" - if self._stop_requested and not self._running: - return - - self._stop_requested = True - if not self._running and self._server is None: - self._dispatch_log("Server is not running.") - return - - self._stop_admin() - 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/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/test_combat_protocol.py b/server/test_combat_protocol.py deleted file mode 100644 index 42477e0..0000000 --- a/server/test_combat_protocol.py +++ /dev/null @@ -1,108 +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.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 bf1592f..0000000 --- a/server/test_npc_protocol.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import json -import socket -import time -import unittest - -from client_session import ClientSession -from server_core import FalloutTogetherServer - - -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.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 test_host_npc_state_is_authoritative_and_broadcast(self) -> None: - self.server._handle_line( - self.host, - json.dumps( - { - "type": "npcState", - "playerId": 999, - "npcs": [ - { - "npcId": 1, - "baseFormId": "0001A4D7", - "x": 1.0, - "y": 2.0, - "z": 3.0, - "angleZ": 0.5, - "cellId": "0000003C", - } - ], - } - ), - ) - - 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": [{"npcId": index + 1} for index in range(17)], - } - ), - ) - - self.client_peer.setblocking(False) - with self.assertRaises(BlockingIOError): - self.client_peer.recv(1) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/CommonwealthOnline.Server.Tests.csproj b/server/tests/CommonwealthOnline.Server.Tests.csproj new file mode 100644 index 0000000..599b4fe --- /dev/null +++ b/server/tests/CommonwealthOnline.Server.Tests.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0 + enable + enable + latest + CommonwealthOnline.Server.Tests + CommonwealthOnline.Server.Tests + + + + + diff --git a/server/tests/Program.cs b/server/tests/Program.cs new file mode 100644 index 0000000..7641655 --- /dev/null +++ b/server/tests/Program.cs @@ -0,0 +1,323 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using CommonwealthOnline.Server; + +namespace CommonwealthOnline.Server.Tests; + +internal static class Program +{ + private static int _passed; + private static int _failed; + + public static async Task Main() + { + await Run("transport policy", TestTransportPolicy); + await Run("packet codec", TestPacketCodec); + await Run("snapshot sequencing", TestSnapshotSequencing); + await Run("snapshot envelope", TestSnapshotEnvelope); + await Run("player state validation", TestPlayerStateValidation); + await Run("npc authority epochs", TestNpcAuthorityEpochs); + await Run("interest filtering", TestInterestFiltering); + await Run("config compatibility", TestConfigCompatibility); + 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; + } + + private static async Task Run(string name, Func test) + { + try { await test(); _passed++; Console.WriteLine($"PASS {name}"); } + catch (Exception ex) { _failed++; Console.Error.WriteLine($"FAIL {name}: {ex.Message}"); } + } + + private static Task TestTransportPolicy() + { + Equal(Delivery.UnreliableSequenced, TransportPolicy.ForPacketType("transform")); + Equal(Delivery.UnreliableSequenced, TransportPolicy.ForPacketType("npcState")); + Equal(Delivery.ReliableOrdered, TransportPolicy.ForPacketType("playerState")); + Equal(Delivery.ReliableOrdered, TransportPolicy.ForPacketType("futureControl")); + return Task.CompletedTask; + } + + private static Task TestPacketCodec() + { + var packet = new JsonObject { ["type"] = "playerState", ["characterName"] = "Nomad" }; + var encoded = PacketCodec.Encode(packet); + Equal("playerState", encoded.PacketType); + Equal(Delivery.ReliableOrdered, encoded.Delivery); + 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 TestSnapshotSequencing() + { + var window = new SequenceWindow(); + True(window.Accept(1)); + True(window.Accept(3)); + False(window.Accept(2)); + False(window.Accept(3)); + True(SequenceWindow.IsNewer(1, uint.MaxValue)); + False(SequenceWindow.IsNewer(uint.MaxValue, 1)); + return Task.CompletedTask; + } + + private static Task TestSnapshotEnvelope() + { + var payload = Encoding.UTF8.GetBytes("{\"type\":\"transform\"}"); + 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); + True(payload.AsSpan().SequenceEqual(envelope.Payload)); + False(GnsSnapshotEnvelope.TryDecode(payload, out _, out _)); + return Task.CompletedTask; + } + + private static Task TestPlayerStateValidation() + { + var packet = new JsonObject + { + ["type"] = "playerState", + ["characterName"] = "Nomad", + ["equippedItems"] = new JsonArray(new JsonObject { ["slot"] = "RightHand", ["formId"] = "ABC" }), + ["appearance"] = new JsonObject + { + ["version"] = 4, + ["raceFormId"] = "13746", + ["hairColorFormId"] = "123", + ["facialHairColorFormId"] = "124", + ["complexionFormId"] = "125", + ["height"] = 1.0, + ["isFemale"] = false, + ["morphWeight"] = new JsonObject { ["thin"] = 0.0, ["muscular"] = 0.0, ["large"] = 0.0 }, + ["bodyTintColor"] = new JsonObject { ["r"] = 255, ["g"] = 255, ["b"] = 255, ["a"] = 255 } + }, + ["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" }) + }; + var normalized = ProtocolValidation.NormalizePlayerState(packet); + NotNull(normalized); + 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 TestNpcAuthorityEpochs() + { + var manager = new NpcAuthorityManager(); + var scope = new ScopeKey("00000001", ""); + 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)); + 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; + } + + private static Task TestInterestFiltering() + { + var a = Transform("00000001", "000000AA", 0, 0); + 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; + } + + private static Task TestConfigCompatibility() + { + var dir = TempDir(); + try + { + var path = Path.Combine(dir, "commonwealth-server.json"); + File.WriteAllText(path, "{\"host\":\"0.0.0.0\",\"port\":7777,\"max_players\":16,\"admin_port\":7779,\"enable_gns_transport\":\"false\"}"); + var options = ServerOptions.Load(path); + False(options.EnableGnsTransport); + Equal(7777, options.Port); + Equal(0, options.Validate().Count); + } + finally { Directory.Delete(dir, true); } + return Task.CompletedTask; + } + + private static Task TestBanPersistence() + { + var dir = TempDir(); + try + { + var path = Path.Combine(dir, "bans.json"); + 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 TestServerOwnedIdsAndInterest() + { + await using var fixture = new ServerFixture(); + var a = new MemoryConnection("a", 31001); + var b = new MemoryConnection("b", 31002); + await Activate(fixture.Server, a, b); + var aId = ReadyId(a); var bId = ReadyId(b); + True(aId != bId); + 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(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 TestDurablePlayerStateRelay() + { + await using var fixture = new ServerFixture(); + var a = new MemoryConnection("a", 32001); var b = new MemoryConnection("b", 32002); + 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(fixture.Server, a, new JsonObject + { + ["type"] = "playerState", + ["characterName"] = "Nomad", + ["actionEvents"] = new JsonArray(new JsonObject { ["sequence"] = 1, ["type"] = 3, ["eventName"] = "fireSingle" }) + }); + var relay = b.SentPackets.Single(p => JsonHelpers.String(p["type"]) == "playerState"); + Equal("Nomad", JsonHelpers.String(relay["characterName"])); + False(relay.ContainsKey("actionEvents")); + } + + private static async Task TestNpcAuthorityEnforcement() + { + await using var fixture = new ServerFixture(); + var a = new MemoryConnection("a", 33001); + 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"); + True(JsonHelpers.TryUInt32(authority["authorityEpoch"], 1, uint.MaxValue, out var epoch)); + a.Clear(); + 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 }) + }); + 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() + { + await using var fixture = new ServerFixture(); + var a = new MemoryConnection("a", 34001); var b = new MemoryConnection("b", 34002); + await Activate(fixture.Server, a, b); + var bId = ReadyId(b); + 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(fixture.Server, a, new JsonObject { ["type"] = "combatHit", ["targetPlayerId"] = bId, ["sequence"] = 1, ["damage"] = 10.0 }); + False(b.SentPackets.Any(p => JsonHelpers.String(p["type"]) == "combatHit")); + True(Stat(fixture.Server, "packetsRejected") >= 1); + } + + private static async Task Activate(AuthoritativeServer server, params MemoryConnection[] connections) + { + foreach (var connection in connections) + { + True(await server.AcceptConnectionAsync(connection, CancellationToken.None)); + await Send(server, connection, new JsonObject { ["type"] = "hello", ["protocolVersion"] = 2 }); + } + } + + 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() + { + ["type"] = "transform", ["x"] = x, ["y"] = y, ["z"] = 0.0, ["angleZ"] = 0.0, + ["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")); + Directory.CreateDirectory(path); + return path; + } + + 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) { 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 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 9dc964f..0000000 --- a/server/tests/test_config_portability.py +++ /dev/null @@ -1,70 +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, - ) - save_config(cfg, str(path)) - - raw = path.read_bytes() - assert b"\r\n" not in raw - assert "Café Server".encode("utf-8") in raw - - loaded = load_config(str(path)) - assert loaded.server_name == "Café Server" - assert loaded.server_description == "résumé" - assert loaded.admin_port == 7779 - - -def test_validate_rejects_bad_ports_and_max_players() -> None: - cfg = Config(port=70000, admin_port=7777, max_players=0) - ok, errors = validate_config(cfg) - assert not ok - assert any("port must be 1-65535" in error for error in errors) - assert any("max_players must be >= 1" in error for error in errors) - - collide = Config(port=7777, admin_port=7777) - ok, errors = validate_config(collide) - assert not ok - assert any("admin_port must differ" in error for error in errors) - - -def test_validate_rejects_unresolvable_host() -> None: - cfg = Config(host="this-host-should-not-resolve.invalid") - ok, errors = validate_config(cfg) - assert not ok - assert any("not a valid IPv4" in error for error in errors) - - -def test_ensure_writable_directory(tmp_path: Path) -> None: - target = tmp_path / "state" - ensure_writable_directory(target) - assert target.is_dir() - assert not (target / ".commonwealth-write-probe").exists() - - -def test_load_invalid_json(tmp_path: Path) -> None: - path = tmp_path / "bad.json" - path.write_text("{not json", encoding="utf-8") - with pytest.raises(Exception): - load_config(str(path)) diff --git a/server/tests/test_runtime_portability.py b/server/tests/test_runtime_portability.py deleted file mode 100644 index 241f8e2..0000000 --- a/server/tests/test_runtime_portability.py +++ /dev/null @@ -1,167 +0,0 @@ -from __future__ import annotations - -import json -import socket -import threading -import time -from pathlib import Path - -import pytest - -from admin_server import send_admin_command -from lan_discovery import LanDiscoveryResponder -from server_core import FalloutTogetherServer, get_lan_addresses -from server_service import ServerConfig, ServerService - - -def _free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def test_get_lan_addresses_never_returns_wildcard() -> None: - addresses = get_lan_addresses() - assert "0.0.0.0" not in addresses - for address in addresses: - assert not address.startswith("127.") - - -def test_discovery_failure_does_not_stop_game_server( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - game_port = _free_port() - admin_port = _free_port() - - def _fail_start(self: LanDiscoveryResponder) -> None: - raise OSError("Could not bind LAN discovery to 0.0.0.0:7778. simulated failure") - - monkeypatch.setattr(LanDiscoveryResponder, "start", _fail_start) - - service = ServerService( - ServerConfig( - host="127.0.0.1", - port=game_port, - admin_port=admin_port, - bans_path=str(tmp_path / "bans.json"), - ) - ) - thread = threading.Thread(target=service.serve_forever, daemon=True) - thread.start() - deadline = time.time() + 5.0 - while time.time() < deadline and not service.is_running(): - time.sleep(0.05) - - assert service.is_running() - with socket.create_connection(("127.0.0.1", game_port), timeout=2.0) as conn: - data = conn.recv(4096) - assert b"welcome" in data - - service.stop() - thread.join(timeout=3.0) - assert not service.is_running() - - -def test_stop_is_idempotent(tmp_path: Path) -> None: - game_port = _free_port() - admin_port = _free_port() - service = ServerService( - ServerConfig( - host="127.0.0.1", - port=game_port, - admin_port=admin_port, - bans_path=str(tmp_path / "bans.json"), - ) - ) - service.start() - deadline = time.time() + 5.0 - while time.time() < deadline and not service.is_running(): - time.sleep(0.05) - assert service.is_running() - - service.stop() - service.stop() - assert not service.is_running() - - -def test_admin_and_game_bind_errors_include_address() -> None: - occupied = _free_port() - holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - holder.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - holder.bind(("127.0.0.1", occupied)) - holder.listen() - - server = FalloutTogetherServer(host="127.0.0.1", port=occupied) - with pytest.raises(OSError, match=rf"127\.0\.0\.1:{occupied}"): - server._prepare_server_socket() - finally: - holder.close() - - -def test_headless_admin_roundtrip(tmp_path: Path) -> None: - game_port = _free_port() - admin_port = _free_port() - service = ServerService( - ServerConfig( - host="127.0.0.1", - port=game_port, - admin_port=admin_port, - bans_path=str(tmp_path / "bans.json"), - ) - ) - thread = threading.Thread(target=service.serve_forever, daemon=True) - thread.start() - deadline = time.time() + 5.0 - while time.time() < deadline and not service.is_running(): - time.sleep(0.05) - assert service.is_running() - - response = send_admin_command({"cmd": "ping"}, port=admin_port) - assert response.get("ok") is True - assert response.get("data", {}).get("pong") is True - - service.stop() - thread.join(timeout=3.0) - - -def test_lan_discovery_sets_broadcast_option() -> None: - class DummyServer: - def get_stats(self): - return { - "connectedClients": 0, - "port": 7777, - "serverName": "Test", - "serverDescription": "", - "maxPlayers": 16, - } - - def _log(self, message: str, *, level: str = "info") -> None: - return None - - # Bind an ephemeral discovery port to avoid colliding with a real server. - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(("127.0.0.1", 0)) - port = int(sock.getsockname()[1]) - sock.close() - - responder = LanDiscoveryResponder(DummyServer(), discovery_port=port) - responder.start() - try: - assert responder._socket is not None - # SO_BROADCAST should be enabled; querying may return 0/1 depending on OS. - value = responder._socket.getsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST) - assert value in (0, 1) - probe = { - "type": "discover", - "protocol": "commonwealth-online", - } - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: - client.settimeout(2.0) - client.sendto(json.dumps(probe).encode("utf-8"), ("127.0.0.1", port)) - data, _addr = client.recvfrom(2048) - packet = json.loads(data.decode("utf-8")) - assert packet["type"] == "discoverResponse" - assert packet["port"] == 7777 - finally: - responder.stop() 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_sigterm_integration.py b/server/tests/test_sigterm_integration.py deleted file mode 100644 index bb6688e..0000000 --- a/server/tests/test_sigterm_integration.py +++ /dev/null @@ -1,98 +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 - -pytestmark = pytest.mark.skipif(os.name == "nt", reason="SIGTERM integration is POSIX-only") - -SERVER_DIR = Path(__file__).resolve().parents[1] - - -def _free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def test_sigterm_exits_cleanly(tmp_path: Path) -> None: - game_port = _free_port() - admin_port = _free_port() - config_path = tmp_path / "commonwealth-server.json" - config_path.write_text( - json.dumps( - { - "host": "127.0.0.1", - "port": game_port, - "server_name": "CI Test Server", - "server_description": "", - "max_players": 4, - "log_verbosity": "info", - "admin_port": admin_port, - }, - indent=2, - ) - + "\n", - encoding="utf-8", - newline="\n", - ) - - env = os.environ.copy() - env["PYTHONUNBUFFERED"] = "1" - env["NO_COLOR"] = "1" - - process = subprocess.Popen( - [ - sys.executable, - "-u", - str(SERVER_DIR / "consumer_server_cli.py"), - "serve", - "--config", - str(config_path), - ], - cwd=str(SERVER_DIR), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=env, - ) - - try: - deadline = time.time() + 10.0 - connected = False - while time.time() < deadline: - try: - with socket.create_connection(("127.0.0.1", game_port), timeout=0.5) as conn: - welcome = conn.recv(4096) - if b"welcome" in welcome: - connected = True - break - except OSError: - time.sleep(0.1) - assert connected, "server did not accept a test client in time" - - # Admin probe over localhost only. - with socket.create_connection(("127.0.0.1", admin_port), timeout=2.0) as admin: - admin.sendall(b'{"cmd":"ping"}\n') - response = admin.recv(4096) - assert b'"ok":true' in response.replace(b" ", b"") - - process.send_signal(signal.SIGTERM) - try: - exit_code = process.wait(timeout=10.0) - except subprocess.TimeoutExpired: - process.kill() - pytest.fail("server did not exit after SIGTERM") - - assert exit_code == 0 - finally: - if process.poll() is None: - process.kill() - process.wait(timeout=5.0) 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/MainWindow.cpp b/src/MainWindow.cpp index 90836e0..985d3cf 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -535,7 +535,7 @@ void MainWindow::onOpenConfig() { QMessageBox::warning( this, QStringLiteral("Server Settings"), - QStringLiteral("Server directory not found. Settings cannot be saved until the Python server folder is available.")); + QStringLiteral("Server directory not found. Settings cannot be saved until the server folder is available.")); return; } @@ -566,7 +566,7 @@ void MainWindow::onServerStarted() { statsTimer->start(1000); // Update every second statusBar()->showMessage("Server running"); addLogMessage("[GUI] Server started successfully"); - // Give the Python admin port a moment to bind before the first poll. + // Give the server admin port a moment to bind before the first poll. QTimer *startupPoll = new QTimer(this); startupPoll->setSingleShot(true); connect(startupPoll, &QTimer::timeout, this, [this, startupPoll]() { diff --git a/src/ServerProcess.cpp b/src/ServerProcess.cpp index 923776d..85aacb1 100644 --- a/src/ServerProcess.cpp +++ b/src/ServerProcess.cpp @@ -1,15 +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) @@ -19,8 +30,8 @@ ServerProcess::ServerProcess(QObject *parent) , m_adminPort(kDefaultAdminPort) , adminFailCount(0) { - pythonPath = findPythonExecutable(); serverDir = findServerDirectory(); + resolveServerLaunch(); } ServerProcess::~ServerProcess() { @@ -37,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)) { @@ -80,139 +82,123 @@ void ServerProcess::stop() { process->waitForFinished(); } } - + process->deleteLater(); + process = nullptr; running = false; adminFailCount = 0; } QJsonObject ServerProcess::sendAdminCommand(const QJsonObject &request) { - QTcpSocket socket; - // Force IPv4 loopback — matches server admin bind on 127.0.0.1. - 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())} - }; + if (serverDir.isEmpty()) { + return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Server directory is unavailable for admin authentication")}}; } - const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact) + '\n'; + QFile tokenFile(QDir(serverDir).absoluteFilePath(QString::fromLatin1(kAdminTokenFileName))); + if (!tokenFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Admin authentication token is not available yet")}}; + } + const QByteArray token = tokenFile.readAll().trimmed(); + if (token.isEmpty()) { + return {{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("Admin authentication token is empty")}}; + } + + QJsonObject authenticatedRequest = request; + authenticatedRequest.insert(QStringLiteral("adminToken"), QString::fromUtf8(token)); + + QTcpSocket socket; + socket.connectToHost(QStringLiteral("127.0.0.1"), static_cast(m_adminPort)); + if (!socket.waitForConnected(kAdminTimeoutMs)) { + 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; - // Avoid spamming the log every second while the admin port is still starting. 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(); @@ -236,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); } @@ -282,53 +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; } } - // Fallback: accept server dirs without admin_server.py so older layouts still launch, - // but prefer ones that include the admin channel when present. - 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