diff --git a/cmake/FetchDependenciesServer.cmake b/cmake/FetchDependenciesServer.cmake new file mode 100644 index 0000000..7727707 --- /dev/null +++ b/cmake/FetchDependenciesServer.cmake @@ -0,0 +1,47 @@ +# cmake/FetchDependenciesServer.cmake +# +# Downloads only what the headless GameServer needs (Eigen + Boost headers). +# Skips anything already present in thirdparty/. Works on Windows and Linux. + +set(THIRDPARTY_DIR "${CMAKE_CURRENT_LIST_DIR}/../thirdparty") +file(MAKE_DIRECTORY "${THIRDPARTY_DIR}") + +macro(server_check_and_download URL ARCHIVE_NAME EXTRACTED_DIR_NAME CHECK_FILE) + set(_archive_path "${THIRDPARTY_DIR}/${ARCHIVE_NAME}") + set(_src_path "${THIRDPARTY_DIR}/${EXTRACTED_DIR_NAME}") + + if(NOT EXISTS "${_src_path}/${CHECK_FILE}") + if(NOT EXISTS "${_archive_path}") + message(STATUS "Downloading ${ARCHIVE_NAME}...") + file(DOWNLOAD "${URL}" "${_archive_path}" + SHOW_PROGRESS STATUS _dl_status) + list(GET _dl_status 0 _dl_code) + if(NOT _dl_code EQUAL 0) + file(REMOVE "${_archive_path}") + message(FATAL_ERROR "Failed to download ${URL}: ${_dl_status}") + endif() + endif() + message(STATUS "Extracting ${ARCHIVE_NAME}...") + execute_process( + COMMAND ${CMAKE_COMMAND} -E tar xf "${_archive_path}" + WORKING_DIRECTORY "${THIRDPARTY_DIR}" + RESULT_VARIABLE _tar_res) + if(NOT _tar_res EQUAL 0) + message(FATAL_ERROR "Failed to extract ${_archive_path}") + endif() + endif() +endmacro() + +# Eigen: only needed if no system install is available. +find_package(Eigen3 3.3 CONFIG QUIET) +if(NOT TARGET Eigen3::Eigen) + server_check_and_download( + "https://gitlab.com/libeigen/eigen/-/archive/5.0.0/eigen-5.0.0.zip" + "eigen-5.0.0.zip" "eigen-5.0.0" "signature_of_eigen3_matrix_library") +endif() + +# Boost (header-only usage: asio + beast). Use bundled copy always so the +# version matches the Windows build. +server_check_and_download( + "https://archives.boost.io/release/1.90.0/source/boost_1_90_0.zip" + "boost_1_90_0.zip" "boost_1_90_0" "boost/asio.hpp") diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 9f9f29a..4059363 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -9,6 +9,7 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/..") find_package(Threads REQUIRED) +include("${GAME_ROOT}/cmake/FetchDependenciesServer.cmake") find_package(Eigen3 3.3 CONFIG QUIET) if(NOT TARGET Eigen3::Eigen) set(EIGEN_BUNDLED_DIR "${GAME_ROOT}/thirdparty/eigen-5.0.0") @@ -105,24 +106,32 @@ endif() add_executable(GameServer src/main.cpp README.md) source_group("Server" FILES src/main.cpp README.md) target_link_libraries(GameServer PRIVATE game_server_core) +set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0") +if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp") + message(FATAL_ERROR + "Boost was not found at thirdparty/boost_1_90_0 and could not be " + "downloaded.") +endif() +target_sources(GameServer PRIVATE + "${GAME_ROOT}/src/game_server/NetworkProtocol.cpp" + "${GAME_ROOT}/src/game_server/NetworkProtocol.h" + "${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp" + "${GAME_ROOT}/src/game_server/WebsocketGameServer.h") +target_include_directories(GameServer PRIVATE "${BOOST_BUNDLED_DIR}") +target_compile_definitions(GameServer PRIVATE NETWORK) if(WIN32) - set(BOOST_BUNDLED_DIR "${GAME_ROOT}/thirdparty/boost_1_90_0") - if(NOT EXISTS "${BOOST_BUNDLED_DIR}/boost/asio.hpp") - message(FATAL_ERROR - "Boost was not found at thirdparty/boost_1_90_0. Configure the " - "graphical project once to populate it.") - endif() - target_sources(GameServer PRIVATE - "${GAME_ROOT}/src/game_server/NetworkProtocol.cpp" - "${GAME_ROOT}/src/game_server/NetworkProtocol.h" - "${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp" - "${GAME_ROOT}/src/game_server/WebsocketGameServer.h") - target_include_directories(GameServer PRIVATE "${BOOST_BUNDLED_DIR}") target_compile_definitions( - GameServer PRIVATE NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) + GameServer PRIVATE WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) target_link_libraries(GameServer PRIVATE ws2_32 mswsock) endif() +# The server reads configs from ./resources relative to its working directory, +# so keep a copy next to the binary after every build. +add_custom_command(TARGET GameServer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${GAME_ROOT}/resources" "$/resources" + COMMENT "Copying resources next to GameServer") + include(CTest) if(BUILD_TESTING) add_executable( @@ -141,7 +150,8 @@ if(BUILD_TESTING) add_test( NAME GameServer.LocationState COMMAND GameServerStateTests) - if(WIN32) + # network tests (all platforms) + if(TRUE) add_executable( GameServerNetworkProtocolTests tests/NetworkProtocolTests.cpp @@ -161,11 +171,16 @@ if(BUILD_TESTING) target_include_directories( GameServerWebsocketLockstepTests PRIVATE "${BOOST_BUNDLED_DIR}") target_compile_definitions( - GameServerWebsocketLockstepTests PRIVATE - NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) + GameServerWebsocketLockstepTests PRIVATE NETWORK) target_link_libraries( - GameServerWebsocketLockstepTests PRIVATE - game_server_core ws2_32 mswsock) + GameServerWebsocketLockstepTests PRIVATE game_server_core) + if(WIN32) + target_compile_definitions( + GameServerWebsocketLockstepTests PRIVATE + WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) + target_link_libraries( + GameServerWebsocketLockstepTests PRIVATE ws2_32 mswsock) + endif() add_test( NAME GameServer.WebsocketLockstep COMMAND GameServerWebsocketLockstepTests) diff --git a/server/README.md b/server/README.md index 772c4f5..6ad46b3 100644 --- a/server/README.md +++ b/server/README.md @@ -1,11 +1,37 @@ # Headless GameServer This target runs the authoritative `InternalGameServer` without SDL, OpenGL, -audio, Lua, or rendering dependencies. On Windows it exposes a deterministic -lockstep session through a Boost.Beast WebSocket listener. +audio, Lua, or rendering dependencies. It exposes a deterministic lockstep +session through a Boost.Beast WebSocket listener (Windows and Linux). + +## Requirements + +- CMake >= 3.16, a C++17 compiler (MSVC, or GCC/Clang on Linux) +- Ubuntu: `sudo apt install build-essential cmake git unzip curl` +- Internet access on the first configure (see below) + +Eigen and Boost are **downloaded automatically** by +`cmake/FetchDependenciesServer.cmake` into `thirdparty/` the first time CMake +configures the project (Boost zip is ~200 MB). A system Eigen3 is used if +installed; Boost is always taken from `thirdparty/boost_1_90_0` so versions match +the Windows build. Nothing else (SDL, zlib, libzip, ...) is needed. ## Build +Linux (recommended helper, 1 compile job to stay within low RAM): + +```bash +server/scripts/build.sh # JOBS=2 server/scripts/build.sh for more +``` + +Or manually: + +```bash +cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release +cmake --build server/build --target GameServer --parallel 1 +ctest --test-dir server/build --output-on-failure +``` + Windows: ```powershell @@ -14,91 +40,126 @@ cmake --build server/build --config Release --target GameServer ctest --test-dir server/build -C Release --output-on-failure ``` -Linux: +> **Memory note:** compiling Boost.Beast code can use 1-1.5 GB per translation +> unit. On a small VDS keep `--parallel 1`; if the compiler gets OOM-killed, add +> temporary swap (`sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && +> sudo mkswap /swapfile && sudo swapon /swapfile`). The running server itself +> needs only a few tens of MB. + +After every build the repository's `resources/` directory is copied next to the +binary (`server/build/resources`, or `server/build/Release/resources` on +Windows). Content paths are resolved from the process working directory, so the +server must be started from the directory that contains `resources/`. + +## Run ```bash -cmake -S server -B server/build -DCMAKE_BUILD_TYPE=Release -cmake --build server/build --target GameServer --parallel -ctest --test-dir server/build --output-on-failure +server/scripts/run-server.sh ``` -Eigen3 must either be installed or be available at -`thirdparty/eigen-5.0.0`. The headless target reads ordinary filesystem resources -only; ZIP-backed campaign loading belongs to the later content-loading -milestone. - -Run until interrupted: +This does `cd server/build` and runs ```bash -./server/build/GameServer +./GameServer --campaign battle_zone_multiplayer --address 127.0.0.1 --port 8081 ``` -For a multi-configuration Windows build, the executable is normally -`server/build/Release/GameServer.exe`. +Environment overrides: `CAMPAIGN`, `LOCATION`, `ADDRESS`, `PORT`, `BUILD_DIR`. -For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the -real-time delay. - -Start the multiplayer lockstep server: +Windows equivalent: ```powershell -.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior +.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer ``` -The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game -afterward and select `battle_zone_multiplayer`; that campaign connects to -`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server. +Command line options: `--campaign ID`, `--location ID`, `--campaigns PATH` +(default `resources/config/campaigns.json`), `--address IP` (default `0.0.0.0`), +`--port N` (default `8080`), `--ticks N` and `--no-sleep` (smoke tests). The server sends one state snapshot when a player connects, then broadcasts one numbered command bundle every 100 ms. Bundles may be empty. Destination input is scheduled two ticks (200 ms) in the future. Tick IDs, rather than server or client wall-clock values, define simulation time. -If `--location` is omitted, the campaign's configured start location is used. -`--campaigns PATH` selects a different campaign registry. Content paths are -resolved from the process working directory, so run from the repository root -when using the bundled `resources/` tree. +## Production deployment (Ubuntu, systemd + nginx) -Available startup options: +The server listens on loopback only (`127.0.0.1:8081`); nginx terminates TLS and +proxies WebSocket traffic. -```text ---campaign ID ---location ID ---campaigns PATH ---address IP ---port N ---tick-ms 100 ---ticks N ---no-sleep +### 1. systemd service + +```bash +sudo cp server/deploy/shadow-gameserver.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now shadow-gameserver +systemctl status shadow-gameserver +journalctl -u shadow-gameserver -f # logs ``` -Network mode requires a 100 ms tick; another `--tick-ms` value is rejected. -The selected campaign's `networkPlayerSlots` lists pre-existing character IDs -assigned to connections in order. `networkCompatibility` is a release token -that must be changed whenever deterministic content changes; clients reject a -different token. The bundled multiplayer Battle Zone has two slots (`player` -and `player2`) and rejects additional clients. Disconnecting -releases the slot. The bot's random autonomous controller is disabled in -multiplayer. +The unit caps memory at 512 MB and restarts on failure. After rebuilding run +`sudo systemctl restart shadow-gameserver`. Edit `User=`/paths in the unit if the +repository lives elsewhere. To run a second server, copy the unit with another +`PORT` (and campaign) and add a matching `/server002/` location. -The graphical client executes presentation behavior after building its runtime -`Location`; the headless content pass loads authoritative navigation, NPC, -interactive-object, trigger, teleport, taxi, and animation metadata. Lua and -dialogue-driven shared multiplayer behavior is not yet supported. +### 2. nginx -## Tests +```bash +sudo cp server/deploy/nginx-api.shadowoverbishkek.com.conf \ + /etc/nginx/sites-available/api.shadowoverbishkek.com +sudo ln -s /etc/nginx/sites-available/api.shadowoverbishkek.com \ + /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` -CTest covers: +`location /server001/` proxies to `http://127.0.0.1:8081/` with WebSocket +upgrade headers and a 1 hour read/send timeout (the trailing slashes strip the +`/server001` prefix). -- repeated `LocationGameState::setup()` preserving mutable runtime state; -- ticking a prepared location with zero players; -- deterministic fixed-100 ms replay behavior; -- protocol-v2 parsing, validation, target normalization, state hashing, and - two-tick command ordering; -- a WebSocket initial-state/slot/destination/tick integration test; -- idle server startup; -- headless loading of `battle_zone_multiplayer/uni_exterior`. +### 3. HTTPS certificate -The lockstep implementation currently targets identical executable and content -builds. Diagnostic state hashes detect divergence, but there is no automatic -checkpoint resynchronization or reconnect input history yet. +DNS for `api.shadowoverbishkek.com` must point at the VDS first. + +```bash +sudo certbot --nginx -d api.shadowoverbishkek.com +``` + +Certbot issues a Let's Encrypt certificate, adds the `443 ssl` block and an HTTP +to HTTPS redirect, and installs auto-renewal (`systemctl list-timers | grep certbot`). + +## Connecting + +Public endpoint: **`wss://api.shadowoverbishkek.com/server001/`** + +Handshake check from any machine (expects `HTTP/1.1 101 Switching Protocols`): + +```bash +curl -i -N --max-time 3 \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ + https://api.shadowoverbishkek.com/server001/ +``` + +Or interactively: `npx wscat -c wss://api.shadowoverbishkek.com/server001/`. + +Locally on the VDS, bypassing nginx: `ws://127.0.0.1:8081`. + +> **Client limitation:** `src/game_server/WebsocketGameClient.cpp` currently +> speaks plain `ws://host:port` to `/` (no TLS, no path). Until TLS and path +> support are added to the client, the graphical game can only connect directly +> to a non-proxied server (e.g. `ws://127.0.0.1:8080`), not to the `wss://` URL. + +## Troubleshooting + +- `Animation metadata failed ... Invalid animation file`: the `.anim` files are + Git LFS pointer stubs (`version https://git-lfs...`). Fetch the real files with + `git lfs pull` (or copy `resources/` from a full checkout) and rebuild. +- `GameServer.LocationState` test currently fails on every platform: it expects + `battle_zone` and `battle_zone_multiplayer` to share a start-state file, but + `campaigns.json` points them to different files. + +- `Server content setup failed`: the server was not started from the directory + containing `resources/` (use `run-server.sh`). +- `WebSocket listener setup failed`: port already in use (`ss -ltnp | grep 8081`). +- `502 Bad Gateway` from nginx: service not running (`systemctl status + shadow-gameserver`). +- Connection drops after ~60 s: check `proxy_read_timeout` in the nginx location. +- Compiler "Killed" / `internal compiler error`: out of memory, see the memory note. diff --git a/server/deploy/nginx-api.shadowoverbishkek.com.conf b/server/deploy/nginx-api.shadowoverbishkek.com.conf new file mode 100644 index 0000000..749fbee --- /dev/null +++ b/server/deploy/nginx-api.shadowoverbishkek.com.conf @@ -0,0 +1,47 @@ +# Install to /etc/nginx/sites-available/api.shadowoverbishkek.com and symlink +# into sites-enabled. Then: sudo certbot --nginx -d api.shadowoverbishkek.com +server { + server_name api.shadowoverbishkek.com; + + access_log /var/log/nginx/api.shadowoverbishkek.com-access.log; + error_log /var/log/nginx/api.shadowoverbishkek.com-error.log; + + # wss://api.shadowoverbishkek.com/server001/ -> 127.0.0.1:8081 + location /server001/ { + proxy_pass http://127.0.0.1:8081/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location / { + return 404; + } + + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/api.shadowoverbishkek.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/api.shadowoverbishkek.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + +} + +server { + if ($host = api.shadowoverbishkek.com) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + server_name api.shadowoverbishkek.com; + + listen 80; + return 404; # managed by Certbot + + +} \ No newline at end of file diff --git a/server/deploy/shadow-gameserver.service b/server/deploy/shadow-gameserver.service new file mode 100644 index 0000000..7c8fe3d --- /dev/null +++ b/server/deploy/shadow-gameserver.service @@ -0,0 +1,15 @@ +[Unit] +Description=Shadow Over Bishkek GameServer (server001) +After=network.target + +[Service] +Type=simple +User=ubuntu +ExecStart=/home/ubuntu/shadowoverbishkek-work/shadow-over-bishkek001/server/scripts/run-server.sh +Environment=PORT=8081 +Restart=on-failure +RestartSec=3 +MemoryMax=512M + +[Install] +WantedBy=multi-user.target diff --git a/server/scripts/build.sh b/server/scripts/build.sh new file mode 100755 index 0000000..86c08cf --- /dev/null +++ b/server/scripts/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Configure and build the headless GameServer (Release). +# Usage: server/scripts/build.sh (uses 1 job: safe for 2 GB RAM hosts) +# JOBS=4 server/scripts/build.sh +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cmake -S "$ROOT/server" -B "$ROOT/server/build" -DCMAKE_BUILD_TYPE=Release +cmake --build "$ROOT/server/build" --target GameServer --parallel "${JOBS:-1}" +echo "Built: $ROOT/server/build/GameServer" diff --git a/server/scripts/run-server.sh b/server/scripts/run-server.sh new file mode 100755 index 0000000..5355f8c --- /dev/null +++ b/server/scripts/run-server.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Start the multiplayer GameServer. Listens on loopback only; nginx proxies +# wss://api.shadowoverbishkek.com/server001/ to it. +# Overrides: CAMPAIGN, LOCATION, ADDRESS, PORT, BUILD_DIR +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUILD_DIR="${BUILD_DIR:-$ROOT/server/build}" +CAMPAIGN="${CAMPAIGN:-battle_zone_multiplayer}" +ADDRESS="${ADDRESS:-127.0.0.1}" +PORT="${PORT:-8081}" + +# Content paths are resolved from the working directory. +cd "$BUILD_DIR" +ARGS=(--campaign "$CAMPAIGN" --address "$ADDRESS" --port "$PORT") +[ -n "${LOCATION:-}" ] && ARGS+=(--location "$LOCATION") +exec ./GameServer "${ARGS[@]}"