diff --git a/.github/workflows/host.yml b/.github/workflows/host.yml new file mode 100644 index 0000000..5960cca --- /dev/null +++ b/.github/workflows/host.yml @@ -0,0 +1,30 @@ +name: Host GUI (Avalonia) + +on: + push: + paths: + - "host/**" + - ".github/workflows/host.yml" + pull_request: + paths: + - "host/**" + - ".github/workflows/host.yml" + workflow_dispatch: + +jobs: + build: + name: Build Avalonia host + runs-on: [self-hosted, Linux, X64] + steps: + - uses: actions/checkout@v4 + + - 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. + env: + DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet + with: + dotnet-version: "8.0.x" + + - name: Build host + run: dotnet build host/CommonwealthOnline.Host.csproj -c Release --nologo diff --git a/.gitignore b/.gitignore index 974c893..b7e8c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ server/bans.json server/.admin-token .DS_Store Thumbs.db + +host/bin/ +host/obj/ diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 4664a52..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,68 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -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_program(DOTNET_EXECUTABLE dotnet REQUIRED) - -set(PROJECT_SOURCES - src/main.cpp - src/MainWindow.h - src/MainWindow.cpp - src/ConfigDialog.h - src/ConfigDialog.cpp - src/ServerProcess.h - src/ServerProcess.cpp - src/resources/resources.qrc -) - -add_executable(CommonwealthOnlineHost ${PROJECT_SOURCES}) -target_link_libraries(CommonwealthOnlineHost Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network Qt6::Concurrent) - -if(WIN32) - 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_target_properties(CommonwealthOnlineHost PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") - -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_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 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 "Publishing and staging C# Commonwealth Online server" - VERBATIM -) diff --git a/README.md b/README.md index 46e7834..ac53b2c 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,42 @@ -# Commonwealth Online Server and Qt Host +# Commonwealth Online — Server & Host -This repository contains the Commonwealth Online authoritative dedicated server and the Qt Host GUI. +The dedicated server and its host GUI for Commonwealth Online (Fallout 4 +multiplayer). Everything now builds on one .NET 8 toolchain. -The dedicated server is C#/.NET. Valve GameNetworkingSockets remains behind the small native C++ bridge in `server/native_transport/`. +## Components -## Server +- **[`server/`](server/README.md)** — the dedicated relay server + (`CommonwealthOnline.Server`, a .NET 8 console app). Run it with + `server/start.sh` / `server/start.bat`, or `CommonwealthOnline.Server serve`. +- **[`host/`](host/README.md)** — the **Avalonia** cross-platform host GUI: + start/stop the server, edit config, watch the log, and manage players + (kick/ban). Replaces the former Qt/C++ host. + +## Quick start — server ```bash cd server -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 +./start.sh # Linux / macOS +start.bat # Windows ``` -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. +Needs the .NET 8 runtime (or the SDK for a source checkout). 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 join. -Transport policy remains: +## Quick start — host GUI -- `transform`, `npcState`: unreliable/sequenced under GNS -- session/control, player state, combat, world state and authority: reliable/ordered - -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. - -## Native GNS bridge - -`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.bat -deploy.bat +```bash +dotnet run --project host/CommonwealthOnline.Host.csproj ``` -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. +## Build -## Default ports +```bash +dotnet build server/CommonwealthOnline.Server.csproj -c Release +dotnet build host/CommonwealthOnline.Host.csproj -c Release +``` -- TCP 7777: gameplay compatibility -- UDP 7777: GNS gameplay when enabled -- UDP 7778: LAN discovery -- TCP 127.0.0.1:7779: authenticated admin control - -See [server/README.md](server/README.md), [SETUP.md](SETUP.md), [DEVELOPMENT.md](DEVELOPMENT.md), and [DEPLOYMENT.md](DEPLOYMENT.md). +> The Qt/C++ host and its CMake build were retired in favour of the Avalonia +> host. `SETUP.md` / `DEVELOPMENT.md` / `DEPLOYMENT.md` are from the Qt era and +> are pending a refresh. diff --git a/build.bat b/build.bat deleted file mode 100644 index f9862c3..0000000 --- a/build.bat +++ /dev/null @@ -1,55 +0,0 @@ -@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 deleted file mode 100644 index 2109b39..0000000 --- a/check-setup.bat +++ /dev/null @@ -1,56 +0,0 @@ -@echo off -setlocal enabledelayedexpansion - -echo. -echo ================================================================================ -echo Commonwealth Online - Qt Host and C# Server Setup Check -echo ================================================================================ -echo. - -cmake --version >nul 2>&1 -if errorlevel 1 ( - echo [ERROR] CMake not found. - exit /b 1 -) -echo [OK] CMake found - -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 - -set QT6_FOUND=0 -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. 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 deleted file mode 100644 index 2d590a5..0000000 --- a/cmake/stage_server.cmake +++ /dev/null @@ -1,69 +0,0 @@ -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_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 "") -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}") -file(COPY "${CO_SERVER_PUBLISH_DIR}/" DESTINATION "${CO_SERVER_STAGE_DIR}") - -if(NOT "${_preserve_config}" STREQUAL "") - file(WRITE "${CO_SERVER_STAGE_CONFIG}" "${_preserve_config}") -elseif(EXISTS "${CO_SERVER_SOURCE_DIR}/commonwealth-server.json") - file(COPY "${CO_SERVER_SOURCE_DIR}/commonwealth-server.json" DESTINATION "${CO_SERVER_STAGE_DIR}") -endif() - -if(NOT "${_preserve_bans}" STREQUAL "") - 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 deleted file mode 100644 index 8e01dbb..0000000 --- a/deploy.bat +++ /dev/null @@ -1,57 +0,0 @@ -@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/find-qt.bat b/find-qt.bat deleted file mode 100644 index cd4278f..0000000 --- a/find-qt.bat +++ /dev/null @@ -1,69 +0,0 @@ -@echo off -setlocal enabledelayedexpansion - -echo. -echo ================================================================================ -echo Qt Installation Finder -echo ================================================================================ -echo. - -REM Check common locations -echo Searching for Qt6 installation... -echo. - -set FOUND=0 - -echo Checking C:\Qt... -if exist "C:\Qt" ( - echo Found C:\Qt directory - dir /b "C:\Qt" | findstr /R "^[0-9]" - set FOUND=1 -) - -echo. -echo Checking C:\Program Files... -if exist "C:\Program Files\Qt" ( - echo Found C:\Program Files\Qt - dir /b "C:\Program Files\Qt" - set FOUND=1 -) - -echo. -echo Checking C:\Program Files ^(x86^)... -if exist "C:\Program Files (x86)\Qt" ( - echo Found C:\Program Files (x86)\Qt - dir /b "C:\Program Files (x86)\Qt" - set FOUND=1 -) - -echo. -echo Checking AppData... -if exist "%APPDATA%\Qt" ( - echo Found %APPDATA%\Qt - dir /b "%APPDATA%\Qt" - set FOUND=1 -) - -if %FOUND%==0 ( - echo. - echo [WARNING] Qt6 not found in common locations! - echo. - echo Please try one of the following: - echo. - echo 1. Install Qt6 from https://www.qt.io/download-open-source - echo Use default installation path: C:\Qt\6.8.0\ - echo. - echo 2. If Qt is installed elsewhere, manually edit build.bat - echo and add your Qt path to the PATHS_TO_CHECK list - echo. - echo 3. Or run CMake manually with explicit path: - echo cmake .. -G "Visual Studio 17 2022" -DCMAKE_PREFIX_PATH="path\to\your\Qt" - echo. - pause - exit /b 1 -) - -echo. -echo Installation search complete! -echo. -pause diff --git a/host/App.axaml b/host/App.axaml new file mode 100644 index 0000000..3ca1064 --- /dev/null +++ b/host/App.axaml @@ -0,0 +1,25 @@ + + + + + + + + + #0E120B + #171C10 + #E6D28C + #D8D2BE + #C24B4B + + + + + + + + + diff --git a/host/App.axaml.cs b/host/App.axaml.cs new file mode 100644 index 0000000..96b355c --- /dev/null +++ b/host/App.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using CommonwealthOnline.Host.ViewModels; +using CommonwealthOnline.Host.Views; + +namespace CommonwealthOnline.Host; + +public partial class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/host/Assets/logo.png b/host/Assets/logo.png new file mode 100644 index 0000000..d879492 Binary files /dev/null and b/host/Assets/logo.png differ diff --git a/host/CommonwealthOnline.Host.csproj b/host/CommonwealthOnline.Host.csproj new file mode 100644 index 0000000..b15accb --- /dev/null +++ b/host/CommonwealthOnline.Host.csproj @@ -0,0 +1,26 @@ + + + + WinExe + net8.0 + enable + latest + true + false + CommonwealthOnline.Host + CommonwealthOnline.Host + + + + + + + + + + + + + + + diff --git a/host/Program.cs b/host/Program.cs new file mode 100644 index 0000000..30f4b76 --- /dev/null +++ b/host/Program.cs @@ -0,0 +1,16 @@ +using System; +using Avalonia; + +namespace CommonwealthOnline.Host; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); +} diff --git a/host/README.md b/host/README.md new file mode 100644 index 0000000..1968935 --- /dev/null +++ b/host/README.md @@ -0,0 +1,31 @@ +# Commonwealth Online — Server Host (Avalonia) + +Cross-platform C# GUI (.NET 8 + Avalonia) for running a Commonwealth Online +dedicated server. Replaces the former Qt/C++ host, so the whole project now +builds on one `dotnet` toolchain. + +## Run + +``` +dotnet run --project host/CommonwealthOnline.Host.csproj +``` + +Launch it from (or point its working directory at) a folder that holds the +server — a published `CommonwealthOnline.Server` executable, the framework +`CommonwealthOnline.Server.dll`, or a source checkout — alongside +`commonwealth-server.json`. + +## Features + +- Edit and save `commonwealth-server.json` (host, port, name, max players, + admin port, log verbosity, GNS toggle). +- Start / Stop the server and stream its output to a live log. +- Live player list with **Kick** / **Ban**, driven by the server's + token-authenticated admin port. + +## Publish + +``` +dotnet publish host/CommonwealthOnline.Host.csproj -c Release -r win-x64 --self-contained false +dotnet publish host/CommonwealthOnline.Host.csproj -c Release -r linux-x64 --self-contained false +``` diff --git a/host/Services/AdminClient.cs b/host/Services/AdminClient.cs new file mode 100644 index 0000000..07a6beb --- /dev/null +++ b/host/Services/AdminClient.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace CommonwealthOnline.Host.Services; + +// Speaks the server's token-authenticated admin protocol on 127.0.0.1:AdminPort: +// read .admin-token, send {..,"adminToken"}\n, read one newline-terminated JSON reply. +public sealed class AdminClient +{ + private const int MaxResponseBytes = 1_000_000; + + private readonly int _port; + private readonly string _tokenPath; + + public AdminClient(int adminPort, string tokenPath) + { + _port = adminPort; + _tokenPath = tokenPath; + } + + public async Task SendAsync(JsonObject request, CancellationToken ct = default) + { + var token = (await File.ReadAllTextAsync(_tokenPath, ct).ConfigureAwait(false)).Trim(); + var authenticated = (JsonObject)request.DeepClone(); + authenticated["adminToken"] = token; + var payload = JsonSerializer.SerializeToUtf8Bytes(authenticated); + + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, _port, ct).ConfigureAwait(false); + var stream = client.GetStream(); + await stream.WriteAsync(payload, ct).ConfigureAwait(false); + await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false); + + using var buffer = new MemoryStream(); + var one = new byte[1]; + while (buffer.Length < MaxResponseBytes) + { + var read = await stream.ReadAsync(one, ct).ConfigureAwait(false); + if (read == 0 || one[0] == (byte)'\n') + { + break; + } + + buffer.WriteByte(one[0]); + } + + return JsonNode.Parse(buffer.ToArray()) as JsonObject; + } + + public Task StatusAsync(CancellationToken ct = default) => + SendAsync(new JsonObject { ["cmd"] = "status" }, ct); + + public Task ClientsAsync(CancellationToken ct = default) => + SendAsync(new JsonObject { ["cmd"] = "clients" }, ct); + + public Task KickAsync(uint playerId, string reason, CancellationToken ct = default) => + SendAsync(new JsonObject { ["cmd"] = "kick", ["playerId"] = playerId, ["reason"] = reason }, ct); + + public Task BanAsync(uint playerId, string reason, CancellationToken ct = default) => + SendAsync(new JsonObject { ["cmd"] = "ban", ["playerId"] = playerId, ["reason"] = reason }, ct); +} diff --git a/host/Services/ServerConfig.cs b/host/Services/ServerConfig.cs new file mode 100644 index 0000000..95bb379 --- /dev/null +++ b/host/Services/ServerConfig.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CommonwealthOnline.Host.Services; + +// Mirrors CommonwealthOnline.Server Configuration. Keep the JSON shape aligned +// with the server's own serializer so a config saved here loads there. +public sealed class ServerConfig +{ + 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; } + + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static ServerConfig Load(string path) + { + try + { + if (File.Exists(path)) + { + return JsonSerializer.Deserialize(File.ReadAllText(path), Options) + ?? new ServerConfig(); + } + } + catch (Exception) + { + // Fall back to defaults on unreadable/invalid config. + } + + return new ServerConfig(); + } + + public void Save(string path) => + File.WriteAllText(path, JsonSerializer.Serialize(this, Options)); +} diff --git a/host/Services/ServerController.cs b/host/Services/ServerController.cs new file mode 100644 index 0000000..f1ee490 --- /dev/null +++ b/host/Services/ServerController.cs @@ -0,0 +1,106 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace CommonwealthOnline.Host.Services; + +// Launches the CommonwealthOnline.Server process, preferring a published +// apphost, then a framework-dependent DLL, then a source-tree dotnet run. +public sealed class ServerController +{ + private Process? _process; + + public bool IsRunning => _process is { HasExited: false }; + + public event Action? LogReceived; + public event Action? RunningChanged; + + public void Start(string serverDir, string configPath) + { + if (IsRunning) + { + return; + } + + var startInfo = ResolveLaunch(serverDir, configPath); + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.OutputDataReceived += (_, e) => Emit(e.Data); + process.ErrorDataReceived += (_, e) => Emit(e.Data); + process.Exited += (_, _) => RunningChanged?.Invoke(false); + + _process = process; + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + RunningChanged?.Invoke(true); + } + + public void Stop() + { + if (_process is { HasExited: false } process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception) + { + // Process already gone or not killable; RunningChanged fires on Exited. + } + } + } + + private void Emit(string? line) + { + if (!string.IsNullOrEmpty(line)) + { + LogReceived?.Invoke(line); + } + } + + private static ProcessStartInfo ResolveLaunch(string serverDir, string configPath) + { + var exeName = OperatingSystem.IsWindows() + ? "CommonwealthOnline.Server.exe" + : "CommonwealthOnline.Server"; + var apphost = Path.Combine(serverDir, exeName); + var dll = Path.Combine(serverDir, "CommonwealthOnline.Server.dll"); + var project = Path.Combine(serverDir, "CommonwealthOnline.Server.csproj"); + + var info = new ProcessStartInfo + { + WorkingDirectory = serverDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + if (File.Exists(apphost)) + { + info.FileName = apphost; + info.ArgumentList.Add("serve"); + } + else if (File.Exists(dll)) + { + info.FileName = "dotnet"; + info.ArgumentList.Add(dll); + info.ArgumentList.Add("serve"); + } + else + { + info.FileName = "dotnet"; + info.ArgumentList.Add("run"); + info.ArgumentList.Add("--project"); + info.ArgumentList.Add(project); + info.ArgumentList.Add("-c"); + info.ArgumentList.Add("Release"); + info.ArgumentList.Add("--"); + info.ArgumentList.Add("serve"); + } + + info.ArgumentList.Add("--config"); + info.ArgumentList.Add(configPath); + return info; + } +} diff --git a/host/ViewModels/MainWindowViewModel.cs b/host/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..7f3b25b --- /dev/null +++ b/host/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommonwealthOnline.Host.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace CommonwealthOnline.Host.ViewModels; + +public partial class MainWindowViewModel : ObservableObject +{ + private const int MaxLogLines = 2000; + + private readonly ServerController _controller = new(); + private readonly string _serverDir = Directory.GetCurrentDirectory(); + private readonly string _configPath = + Path.Combine(Directory.GetCurrentDirectory(), "commonwealth-server.json"); + private readonly DispatcherTimer _pollTimer; + private bool _polling; + + [ObservableProperty] private string _serverName; + [ObservableProperty] private string _host; + [ObservableProperty] private int _port; + [ObservableProperty] private int _maxPlayers; + [ObservableProperty] private int _adminPort; + [ObservableProperty] private string _logVerbosity; + [ObservableProperty] private bool _enableGnsTransport; + [ObservableProperty] private bool _isRunning; + [ObservableProperty] private string _statusText = "Stopped"; + [ObservableProperty] private string _statsText = string.Empty; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(KickCommand))] + [NotifyCanExecuteChangedFor(nameof(BanCommand))] + private PlayerRow? _selectedPlayer; + + public ObservableCollection Log { get; } = new(); + public ObservableCollection Players { get; } = new(); + + public string[] VerbosityOptions { get; } = { "error", "warning", "info", "debug" }; + + public MainWindowViewModel() + { + var config = ServerConfig.Load(_configPath); + _serverName = config.ServerName; + _host = config.Host; + _port = config.Port; + _maxPlayers = config.MaxPlayers; + _adminPort = config.AdminPort; + _logVerbosity = config.LogVerbosity; + _enableGnsTransport = config.EnableGnsTransport; + + _controller.LogReceived += line => + Dispatcher.UIThread.Post(() => Append(line)); + _controller.RunningChanged += running => + Dispatcher.UIThread.Post(() => + { + IsRunning = running; + StatusText = running ? "Running" : "Stopped"; + StartCommand.NotifyCanExecuteChanged(); + StopCommand.NotifyCanExecuteChanged(); + if (!running) + { + Players.Clear(); + StatsText = string.Empty; + } + }); + + _pollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; + _pollTimer.Tick += async (_, _) => await PollAsync(); + _pollTimer.Start(); + } + + private AdminClient CreateAdminClient() + { + var tokenPath = Path.Combine(Path.GetDirectoryName(_configPath) ?? _serverDir, ".admin-token"); + return new AdminClient(AdminPort, tokenPath); + } + + private async Task PollAsync() + { + if (!IsRunning || _polling) + { + return; + } + + _polling = true; + try + { + var admin = CreateAdminClient(); + var clients = await admin.ClientsAsync().ConfigureAwait(true); + ApplyClients(clients); + + var status = await admin.StatusAsync().ConfigureAwait(true); + ApplyStatus(status); + } + catch (Exception) + { + // Server still starting, admin port not up yet, or token not written — ignore this tick. + } + finally + { + _polling = false; + } + } + + private void ApplyClients(JsonObject? response) + { + if (response?["clients"] is not JsonArray array) + { + return; + } + + var previouslySelected = SelectedPlayer?.PlayerId; + Players.Clear(); + foreach (var node in array) + { + if (node is not JsonObject client) + { + continue; + } + + Players.Add(new PlayerRow + { + PlayerId = (uint)(client["player_id"]?.GetValue() ?? 0), + Label = client["label"]?.GetValue() ?? string.Empty, + Address = client["address"]?.GetValue() ?? string.Empty, + PacketsReceived = client["packets_received"]?.GetValue() ?? 0, + PacketsSent = client["packets_sent"]?.GetValue() ?? 0, + }); + } + + if (previouslySelected is { } id) + { + foreach (var row in Players) + { + if (row.PlayerId == id) + { + SelectedPlayer = row; + break; + } + } + } + } + + private void ApplyStatus(JsonObject? response) + { + if (response is null) + { + return; + } + + var connected = response["connected_clients"]?.GetValue() ?? Players.Count; + var uptime = response["uptime_seconds"]?.GetValue() ?? 0; + StatsText = $"{connected}/{MaxPlayers} players · up {uptime}s"; + } + + private void Append(string line) + { + Log.Add(line); + while (Log.Count > MaxLogLines) + { + Log.RemoveAt(0); + } + } + + private ServerConfig CurrentConfig() => new() + { + ServerName = ServerName, + Host = Host, + Port = Port, + MaxPlayers = MaxPlayers, + AdminPort = AdminPort, + LogVerbosity = LogVerbosity, + EnableGnsTransport = EnableGnsTransport, + }; + + [RelayCommand] + private void Save() => CurrentConfig().Save(_configPath); + + [RelayCommand(CanExecute = nameof(CanStart))] + private void Start() + { + Save(); + Append($"[host] starting server on {Host}:{Port}..."); + _controller.Start(_serverDir, _configPath); + } + + private bool CanStart() => !IsRunning; + + [RelayCommand(CanExecute = nameof(CanStop))] + private void Stop() + { + Append("[host] stopping server..."); + _controller.Stop(); + } + + private bool CanStop() => IsRunning; + + [RelayCommand(CanExecute = nameof(CanActOnPlayer))] + private async Task Kick() + { + if (SelectedPlayer is not { } player) + { + return; + } + + await RunAdminAction(admin => admin.KickAsync(player.PlayerId, "Kicked by host"), + $"[host] kick #{player.PlayerId}").ConfigureAwait(true); + } + + [RelayCommand(CanExecute = nameof(CanActOnPlayer))] + private async Task Ban() + { + if (SelectedPlayer is not { } player) + { + return; + } + + await RunAdminAction(admin => admin.BanAsync(player.PlayerId, "Banned by host"), + $"[host] ban #{player.PlayerId}").ConfigureAwait(true); + } + + private bool CanActOnPlayer() => IsRunning && SelectedPlayer is not null; + + private async Task RunAdminAction(Func> action, string label) + { + try + { + var response = await action(CreateAdminClient()).ConfigureAwait(true); + var message = response?["message"]?.GetValue(); + Append(string.IsNullOrEmpty(message) ? $"{label} sent" : $"{label}: {message}"); + await PollAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + Append($"{label} failed: {ex.Message}"); + } + } +} diff --git a/host/ViewModels/PlayerRow.cs b/host/ViewModels/PlayerRow.cs new file mode 100644 index 0000000..267747a --- /dev/null +++ b/host/ViewModels/PlayerRow.cs @@ -0,0 +1,13 @@ +namespace CommonwealthOnline.Host.ViewModels; + +public sealed class PlayerRow +{ + public uint PlayerId { get; init; } + public string Label { get; init; } = string.Empty; + public string Address { get; init; } = string.Empty; + public long PacketsReceived { get; init; } + public long PacketsSent { get; init; } + + public string Display => + $"#{PlayerId} {(string.IsNullOrEmpty(Label) ? "player" : Label)} {Address} ↓{PacketsReceived} ↑{PacketsSent}"; +} diff --git a/host/Views/MainWindow.axaml b/host/Views/MainWindow.axaml new file mode 100644 index 0000000..810293b --- /dev/null +++ b/host/Views/MainWindow.axaml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + +