Compare commits

..
1 Commits
Author SHA1 Message Date
Vladislav Khorev 28fb81ad45 Working with HTTPS 2026-11-09 23:28:18 +06:00
14 changed files with 489 additions and 348 deletions

No files matched your search

+12 -2
View File
@@ -41,7 +41,7 @@ configured player slot connects or disconnects.
`WebsocketGameClient` implements `IGameServer`.
- It connects asynchronously to `ws://127.0.0.1:8080/`.
- It connects asynchronously to a `ws://` or `wss://` endpoint (`WebsocketEndpoint::parse`).
- Received messages are queued by the network thread and applied on the game
thread from `update()`.
- It maintains an internal `InternalGameServer` replica so existing snapshot
@@ -145,6 +145,13 @@ Networking is enabled for Windows and the Emscripten web client:
replica, and remote-host code, and links Emscripten's `websocket.js` library.
`WebsocketGameClient.cpp` selects the browser WebSocket API on Emscripten; it
does not use Boost.Asio or Boost.Beast there.
- `wss://` (TLS) is supported by the Windows client only. `proj-windows` also defines
`NETWORK_TLS` and links static OpenSSL 3.4.1, which CMake downloads and builds
automatically at configure time (`cmake/ThirdParty.cmake`, section 12) using a
downloaded portable Strawberry Perl and the nmake from the installed Visual Studio
(located through `vswhere`). Builds go to `thirdparty/openssl-3.4.1/install-Debug`
and `install-Release`; later configures skip them. `NETWORK_TLS` unset (server tests)
means `wss://` endpoints fail with a logged error.
- Linux and Android builds do not define `NETWORK` and do not compile the
network transport.
@@ -196,7 +203,10 @@ submission, and contiguous tick consumption.
not implemented.
- There is no authentication or reconnect/replay history.
- A hash mismatch is logged but does not yet trigger checkpoint resynchronization.
- The endpoint is hardcoded in the graphical client.
- The endpoint is hardcoded in the graphical client (`kMultiplayerServerUrl` in
`Game.cpp`, currently `wss://api.shadowoverbishkek.com/server001/`).
- TLS certificate or hostname problems are logged but deliberately do not abort the
connection. `GameServer` itself has no TLS; it must sit behind a TLS-terminating proxy.
- Multiplayer Lua and dialogue behavior remains unsupported.
- Determinism is supported for identical executable/content builds. The
floating-point simulation is not guaranteed bit-identical across platforms
+27
View File
@@ -56,3 +56,30 @@ check_and_download("https://github.com/ThePhD/sol2/archive/refs/tags/v3.3.0.zip"
# 11) SDL2_mixer
check_and_download("https://github.com/libsdl-org/SDL_mixer/archive/refs/tags/release-2.8.0.zip" "SDL_mixer-release-2.8.0.zip" "SDL_mixer-release-2.8.0" "CMakeLists.txt")
# 12) OpenSSL (TLS for wss://) and a native Windows Perl needed to build it
if(WIN32)
check_and_download("https://github.com/openssl/openssl/releases/download/openssl-3.4.1/openssl-3.4.1.tar.gz" "openssl-3.4.1.tar.gz" "openssl-3.4.1" "Configure")
# Strawberry Perl portable. The zip has no top-level folder, so it is
# extracted into its own directory. (Git's MSYS perl cannot build OpenSSL.)
set(STRAWBERRY_PERL_DIR "${THIRDPARTY_DIR}/strawberry-perl")
set(STRAWBERRY_PERL_ARCHIVE "${THIRDPARTY_DIR}/strawberry-perl-5.42.3.1-64bit-portable.zip")
if(NOT EXISTS "${STRAWBERRY_PERL_DIR}/perl/bin/perl.exe")
if(NOT EXISTS "${STRAWBERRY_PERL_ARCHIVE}")
message(STATUS "Downloading Strawberry Perl...")
file(DOWNLOAD
"https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/SP_54231_64bit/strawberry-perl-5.42.3.1-64bit-portable.zip"
"${STRAWBERRY_PERL_ARCHIVE}" SHOW_PROGRESS)
endif()
message(STATUS "Extracting Strawberry Perl...")
file(MAKE_DIRECTORY "${STRAWBERRY_PERL_DIR}")
execute_process(
COMMAND ${CMAKE_COMMAND} -E tar xf "${STRAWBERRY_PERL_ARCHIVE}"
WORKING_DIRECTORY "${STRAWBERRY_PERL_DIR}"
)
if(NOT EXISTS "${STRAWBERRY_PERL_DIR}/perl/bin/perl.exe")
message(FATAL_ERROR "Strawberry Perl extraction failed")
endif()
endif()
endif()
-47
View File
@@ -1,47 +0,0 @@
# 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")
+103 -1
View File
@@ -725,4 +725,106 @@ set_target_properties(SDL2_mixer_external_lib PROPERTIES
"$<IF:$<CONFIG:Debug>,${SDL2MIXER_BASE_DIR}-Debug/include,${SDL2MIXER_BASE_DIR}-Release/include>"
INTERFACE_LINK_LIBRARIES
"SDL2_external_lib"
)
)
# ===========================================
# 12) OpenSSL (3.4.1) - TLS for wss:// (Windows only, static libs)
# ===========================================
if(WIN32)
set(OPENSSL_SRC_DIR "${THIRDPARTY_DIR}/openssl-3.4.1")
set(OPENSSL_BASE_DIR "${OPENSSL_SRC_DIR}/install")
set(_have_openssl TRUE)
foreach(cfg IN LISTS BUILD_CONFIGS)
if(NOT EXISTS "${OPENSSL_BASE_DIR}-${cfg}/lib/libssl.lib" OR
NOT EXISTS "${OPENSSL_BASE_DIR}-${cfg}/lib/libcrypto.lib")
set(_have_openssl FALSE)
endif()
endforeach()
if(NOT _have_openssl)
# Locate Visual Studio's vcvarsall.bat (provides cl, link, nmake).
set(_vswhere "$ENV{ProgramFiles\(x86\)}/Microsoft Visual Studio/Installer/vswhere.exe")
set(_vcvarsall "")
if(EXISTS "${_vswhere}")
execute_process(
COMMAND "${_vswhere}" -latest -products * -property installationPath
OUTPUT_VARIABLE _vs_path
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(_vs_path AND EXISTS "${_vs_path}/VC/Auxiliary/Build/vcvarsall.bat")
set(_vcvarsall "${_vs_path}/VC/Auxiliary/Build/vcvarsall.bat")
endif()
endif()
if(_vcvarsall STREQUAL "")
message(FATAL_ERROR "Visual Studio (vcvarsall.bat) not found; it is required to build OpenSSL")
endif()
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
set(_ossl_vc_arch x64)
set(_ossl_target VC-WIN64A)
else()
set(_ossl_vc_arch x86)
set(_ossl_target VC-WIN32)
endif()
file(TO_NATIVE_PATH "${_vcvarsall}" _vcvarsall_native)
file(TO_NATIVE_PATH "${STRAWBERRY_PERL_DIR}/perl/bin" _perl_bin_native)
file(TO_NATIVE_PATH "${STRAWBERRY_PERL_DIR}/perl/site/bin" _perl_site_native)
foreach(cfg IN LISTS BUILD_CONFIGS)
set(_ossl_build "${OPENSSL_SRC_DIR}/build-${cfg}")
file(REMOVE_RECURSE "${_ossl_build}")
file(MAKE_DIRECTORY "${_ossl_build}")
file(TO_NATIVE_PATH "${_ossl_build}" _ossl_build_native)
file(TO_NATIVE_PATH "${OPENSSL_SRC_DIR}/Configure" _ossl_configure_native)
file(TO_NATIVE_PATH "${OPENSSL_BASE_DIR}-${cfg}" _ossl_prefix_native)
set(_ossl_cfg_flags "")
if(cfg STREQUAL "Debug")
# /MDd: link the dynamic debug CRT like the game (static libs default to /MTd)
set(_ossl_cfg_flags "--debug /MDd")
else()
set(_ossl_cfg_flags "--release /MD")
endif()
# A batch file avoids cmd.exe quoting problems.
set(_ossl_bat "${_ossl_build}/build_openssl.bat")
file(WRITE "${_ossl_bat}"
"@echo off
call \"${_vcvarsall_native}\" ${_ossl_vc_arch} || exit /b 1
set \"PATH=${_perl_bin_native};${_perl_site_native};%PATH%\"
set CL=/MP
cd /d \"${_ossl_build_native}\" || exit /b 1
perl \"${_ossl_configure_native}\" ${_ossl_target} no-shared no-asm no-tests no-docs ${_ossl_cfg_flags} --prefix=\"${_ossl_prefix_native}\" --openssldir=\"${_ossl_prefix_native}/ssl\" || exit /b 1
nmake || exit /b 1
nmake install_sw || exit /b 1
")
log("Building OpenSSL (${cfg}) - this takes several minutes ...")
execute_process(
COMMAND cmd /c "${_ossl_bat}"
RESULT_VARIABLE _ossl_res
)
if(NOT _ossl_res EQUAL 0)
message(FATAL_ERROR "OpenSSL build failed for ${cfg} (see output above)")
endif()
endforeach()
endif()
add_library(openssl_crypto_external_lib UNKNOWN IMPORTED GLOBAL)
set_target_properties(openssl_crypto_external_lib PROPERTIES
IMPORTED_LOCATION_DEBUG "${OPENSSL_BASE_DIR}-Debug/lib/libcrypto.lib"
IMPORTED_LOCATION_RELEASE "${OPENSSL_BASE_DIR}-Release/lib/libcrypto.lib"
INTERFACE_INCLUDE_DIRECTORIES
"$<IF:$<CONFIG:Debug>,${OPENSSL_BASE_DIR}-Debug/include,${OPENSSL_BASE_DIR}-Release/include>"
INTERFACE_LINK_LIBRARIES "ws2_32;crypt32;advapi32;user32;gdi32"
)
add_library(openssl_ssl_external_lib UNKNOWN IMPORTED GLOBAL)
set_target_properties(openssl_ssl_external_lib PROPERTIES
IMPORTED_LOCATION_DEBUG "${OPENSSL_BASE_DIR}-Debug/lib/libssl.lib"
IMPORTED_LOCATION_RELEASE "${OPENSSL_BASE_DIR}-Release/lib/libssl.lib"
INTERFACE_INCLUDE_DIRECTORIES
"$<IF:$<CONFIG:Debug>,${OPENSSL_BASE_DIR}-Debug/include,${OPENSSL_BASE_DIR}-Release/include>"
INTERFACE_LINK_LIBRARIES "openssl_crypto_external_lib"
)
endif()
+3 -1
View File
@@ -143,6 +143,7 @@ target_compile_definitions(ShadowOverBishkekDemo PRIVATE
_WIN32_WINNT=0x0A00
PNG_ENABLED
NETWORK
NETWORK_TLS
)
# Линкуем с SDL2main, если он вообще установлен
@@ -159,7 +160,8 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE
eigen_external_lib
boost_external_lib
sol2_external_lib
SDL2_mixer_external_lib
SDL2_mixer_external_lib
openssl_ssl_external_lib
)
# Линкуем OpenGL (Windows)
+18 -33
View File
@@ -9,7 +9,6 @@ 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")
@@ -106,32 +105,24 @@ 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 WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
GameServer PRIVATE NETWORK 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" "$<TARGET_FILE_DIR:GameServer>/resources"
COMMENT "Copying resources next to GameServer")
include(CTest)
if(BUILD_TESTING)
add_executable(
@@ -150,8 +141,7 @@ if(BUILD_TESTING)
add_test(
NAME GameServer.LocationState
COMMAND GameServerStateTests)
# network tests (all platforms)
if(TRUE)
if(WIN32)
add_executable(
GameServerNetworkProtocolTests
tests/NetworkProtocolTests.cpp
@@ -171,16 +161,11 @@ if(BUILD_TESTING)
target_include_directories(
GameServerWebsocketLockstepTests PRIVATE "${BOOST_BUNDLED_DIR}")
target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE NETWORK)
GameServerWebsocketLockstepTests PRIVATE
NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
target_link_libraries(
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()
GameServerWebsocketLockstepTests PRIVATE
game_server_core ws2_32 mswsock)
add_test(
NAME GameServer.WebsocketLockstep
COMMAND GameServerWebsocketLockstepTests)
+62 -123
View File
@@ -1,37 +1,11 @@
# Headless GameServer
This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
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.
audio, Lua, or rendering dependencies. On Windows it exposes a deterministic
lockstep session through a Boost.Beast WebSocket listener.
## 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
@@ -40,126 +14,91 @@ cmake --build server/build --config Release --target GameServer
ctest --test-dir server/build -C Release --output-on-failure
```
> **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
Linux:
```bash
server/scripts/run-server.sh
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
```
This does `cd server/build` and runs
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:
```bash
./GameServer --campaign battle_zone_multiplayer --address 127.0.0.1 --port 8081
./server/build/GameServer
```
Environment overrides: `CAMPAIGN`, `LOCATION`, `ADDRESS`, `PORT`, `BUILD_DIR`.
For a multi-configuration Windows build, the executable is normally
`server/build/Release/GameServer.exe`.
Windows equivalent:
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
real-time delay.
Start the multiplayer lockstep server:
```powershell
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
```
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 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.
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.
## Production deployment (Ubuntu, systemd + nginx)
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.
The server listens on loopback only (`127.0.0.1:8081`); nginx terminates TLS and
proxies WebSocket traffic.
Available startup options:
### 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
```text
--campaign ID
--location ID
--campaigns PATH
--address IP
--port N
--tick-ms 100
--ticks N
--no-sleep
```
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.
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.
### 2. nginx
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.
```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
```
## Tests
`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).
CTest covers:
### 3. HTTPS certificate
- 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`.
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.
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.
@@ -1,47 +0,0 @@
# 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
}
-15
View File
@@ -1,15 +0,0 @@
[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
-9
View File
@@ -1,9 +0,0 @@
#!/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"
-16
View File
@@ -1,16 +0,0 @@
#!/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[@]}"
+9 -1
View File
@@ -557,7 +557,15 @@ namespace FRG
if (remoteCampaignActive) {
logger() << "[network] Campaign '" << campaignId
<< "' selected; creating WebSocket client" << std::endl;
websocketGameClient = std::make_unique<WebsocketGameClient>();
// Use "ws://127.0.0.1:8080/" for a local development server.
static const char* const kMultiplayerServerUrl =
"wss://api.shadowoverbishkek.com/server001/";
WebsocketEndpoint endpoint;
if (!endpoint.parse(kMultiplayerServerUrl)) {
logger() << "[network] Invalid multiplayer server URL: "
<< kMultiplayerServerUrl << std::endl;
}
websocketGameClient = std::make_unique<WebsocketGameClient>(endpoint);
remoteGameServerHost =
std::make_unique<RemoteGameServerHost>(*websocketGameClient);
gameServer.use(*websocketGameClient);
+240 -52
View File
@@ -5,6 +5,21 @@
#else
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#ifdef NETWORK_TLS
#include <wincrypt.h>
// wincrypt.h defines macros that collide with OpenSSL identifiers.
#undef X509_NAME
#undef X509_EXTENSIONS
#undef X509_CERT_PAIR
#undef PKCS7_ISSUER_AND_SERIAL
#undef OCSP_REQUEST
#undef OCSP_RESPONSE
#include <boost/asio/ssl.hpp>
#include <boost/beast/ssl.hpp>
#include <openssl/err.h>
#include <openssl/x509.h>
#include <openssl/x509_vfy.h>
#endif
#endif
#include <atomic>
#include <deque>
@@ -23,19 +38,104 @@ namespace asio = boost::asio;
namespace beast = boost::beast;
namespace websocket = beast::websocket;
using tcp = asio::ip::tcp;
#ifdef NETWORK_TLS
// Loads the Windows "ROOT" certificate store into the OpenSSL context.
void loadWindowsRootCertificates(asio::ssl::context& context)
{
HCERTSTORE store = CertOpenSystemStoreA(0, "ROOT");
if (!store) {
std::cerr << "[network] Could not open the Windows root certificate store\n";
return;
}
X509_STORE* x509Store = SSL_CTX_get_cert_store(context.native_handle());
PCCERT_CONTEXT cert = nullptr;
while ((cert = CertEnumCertificatesInStore(store, cert)) != nullptr) {
const unsigned char* data = cert->pbCertEncoded;
X509* x509 = d2i_X509(nullptr, &data, static_cast<long>(cert->cbCertEncoded));
if (x509) {
X509_STORE_add_cert(x509Store, x509);
X509_free(x509);
}
}
CertCloseStore(store, 0);
}
// Certificate problems are logged but deliberately do not abort the connection.
bool logAndAcceptCertificate(bool preverified, asio::ssl::verify_context& context)
{
if (!preverified) {
X509_STORE_CTX* storeContext = context.native_handle();
const int error = X509_STORE_CTX_get_error(storeContext);
const int depth = X509_STORE_CTX_get_error_depth(storeContext);
char subject[256] = "?";
if (X509* cert = X509_STORE_CTX_get_current_cert(storeContext))
X509_NAME_oneline(X509_get_subject_name(cert), subject, sizeof(subject));
std::cerr << "[network] TLS certificate warning (depth " << depth
<< ", subject " << subject << "): "
<< X509_verify_cert_error_string(error)
<< " - continuing anyway\n";
}
return true;
}
#endif
#endif
} // namespace
bool WebsocketEndpoint::parse(const std::string& url)
{
bool tls = false;
std::string rest;
if (url.rfind("wss://", 0) == 0) {
tls = true;
rest = url.substr(6);
} else if (url.rfind("ws://", 0) == 0) {
rest = url.substr(5);
} else {
return false;
}
std::string authority = rest;
std::string newPath = "/";
const std::size_t slash = rest.find('/');
if (slash != std::string::npos) {
authority = rest.substr(0, slash);
newPath = rest.substr(slash);
}
std::string newHost = authority;
std::string newPort = tls ? "443" : "80";
if (!authority.empty() && authority.front() == '[') { // [ipv6]:port
const std::size_t close = authority.find(']');
if (close == std::string::npos) return false;
newHost = authority.substr(1, close - 1);
if (close + 1 < authority.size()) {
if (authority[close + 1] != ':') return false;
newPort = authority.substr(close + 2);
}
} else {
const std::size_t colon = authority.rfind(':');
if (colon != std::string::npos) {
newHost = authority.substr(0, colon);
newPort = authority.substr(colon + 1);
}
}
if (newHost.empty() || newPort.empty()) return false;
host = std::move(newHost);
port = std::move(newPort);
path = std::move(newPath);
useTls = tls;
return true;
}
class WebsocketGameClient::Impl {
public:
#ifdef __EMSCRIPTEN__
Impl(std::string host, std::string port, std::string path)
Impl(std::string host, std::string port, std::string path, bool useTls)
: host_(std::move(host))
, port_(std::move(port))
, path_(std::move(path))
{
url_ = "ws://" + host_ + ':' + port_ +
url_ = std::string(useTls ? "wss://" : "ws://") + host_ + ':' + port_ +
(path_.empty() || path_.front() == '/' ? path_ : "/" + path_);
std::cout << "[network] Opening " << url_ << std::endl;
@@ -143,14 +243,38 @@ private:
bool closing_ = false;
std::vector<std::string> messages_;
#else
Impl(std::string host, std::string port, std::string path)
: resolver_(io_)
Impl(std::string host, std::string port, std::string path, bool useTls)
:
#ifdef NETWORK_TLS
sslContext_(asio::ssl::context::tls_client)
, resolver_(io_)
, secureSocket_(io_, sslContext_)
#else
resolver_(io_)
#endif
, socket_(io_)
, host_(std::move(host))
, port_(std::move(port))
, path_(std::move(path))
, useTls_(useTls)
{
std::cout << "[network] Resolving ws://" << host_ << ':' << port_
#ifdef NETWORK_TLS
sslContext_.set_options(
asio::ssl::context::default_workarounds |
asio::ssl::context::no_sslv2 | asio::ssl::context::no_sslv3 |
asio::ssl::context::no_tlsv1 | asio::ssl::context::no_tlsv1_1);
loadWindowsRootCertificates(sslContext_);
sslContext_.set_verify_mode(asio::ssl::verify_peer);
sslContext_.set_verify_callback(&logAndAcceptCertificate);
#else
if (useTls_) {
std::cerr << "[network] wss:// requested but TLS support was not "
"compiled in (NETWORK_TLS)" << std::endl;
connectionFailed_.store(true);
return;
}
#endif
std::cout << "[network] Resolving " << scheme() << host_ << ':' << port_
<< path_ << std::endl;
resolve();
thread_ = std::thread([this]() { io_.run(); });
@@ -184,6 +308,20 @@ private:
}
private:
const char* scheme() const { return useTls_ ? "wss://" : "ws://"; }
template <class F>
void withStream(F&& f)
{
#ifdef NETWORK_TLS
if (useTls_) {
f(secureSocket_);
return;
}
#endif
f(socket_);
}
void resolve()
{
resolver_.async_resolve(
@@ -192,60 +330,97 @@ private:
if (error) return fail("resolve", error);
std::cout << "[network] WebSocket host resolved; connecting"
<< std::endl;
asio::async_connect(
socket_.next_layer(), results,
[this](beast::error_code connectError,
const tcp::endpoint&) {
if (connectError) return fail("connect", connectError);
std::cout << "[network] TCP connected; starting WebSocket handshake"
<< std::endl;
socket_.set_option(
websocket::stream_base::timeout::suggested(
beast::role_type::client));
socket_.async_handshake(
host_, path_,
[this](beast::error_code handshakeError) {
if (handshakeError)
return fail("handshake", handshakeError);
connected_.store(true);
std::cout
<< "[network] Connected to ws://"
<< host_ << ':' << port_ << path_ << std::endl;
read();
});
});
withStream([&](auto& stream) {
asio::async_connect(
beast::get_lowest_layer(stream), results,
[this](beast::error_code connectError,
const tcp::endpoint&) {
if (connectError) return fail("connect", connectError);
std::cout << "[network] TCP connected" << std::endl;
startTlsOrWebsocketHandshake();
});
});
});
}
void startTlsOrWebsocketHandshake()
{
#ifdef NETWORK_TLS
if (useTls_) {
SSL* ssl = secureSocket_.next_layer().native_handle();
// SNI, and hostname verification (failures are only logged).
SSL_set_tlsext_host_name(ssl, const_cast<char*>(host_.c_str()));
SSL_set1_host(ssl, host_.c_str());
std::cout << "[network] Starting TLS handshake" << std::endl;
secureSocket_.next_layer().async_handshake(
asio::ssl::stream_base::client,
[this](beast::error_code tlsError) {
if (tlsError) return fail("TLS handshake", tlsError);
std::cout << "[network] TLS established" << std::endl;
startWebsocketHandshake();
});
return;
}
#endif
startWebsocketHandshake();
}
void startWebsocketHandshake()
{
const bool defaultPort = port_ == (useTls_ ? "443" : "80");
const std::string hostHeader = defaultPort ? host_ : host_ + ':' + port_;
std::cout << "[network] Starting WebSocket handshake" << std::endl;
withStream([&](auto& stream) {
stream.set_option(
websocket::stream_base::timeout::suggested(
beast::role_type::client));
stream.async_handshake(
hostHeader, path_,
[this](beast::error_code handshakeError) {
if (handshakeError)
return fail("handshake", handshakeError);
connected_.store(true);
std::cout
<< "[network] Connected to " << scheme()
<< host_ << ':' << port_ << path_ << std::endl;
read();
});
});
}
void read()
{
socket_.async_read(
buffer_,
[this](beast::error_code error, std::size_t) {
if (error) {
connected_.store(false);
if (error != websocket::error::closed) fail("read", error);
return;
}
std::string message = beast::buffers_to_string(buffer_.data());
buffer_.consume(buffer_.size());
{
std::lock_guard<std::mutex> lock(messagesMutex_);
messages_.push_back(std::move(message));
}
read();
});
withStream([this](auto& stream) {
stream.async_read(
buffer_,
[this](beast::error_code error, std::size_t) {
if (error) {
connected_.store(false);
if (error != websocket::error::closed) fail("read", error);
return;
}
std::string message = beast::buffers_to_string(buffer_.data());
buffer_.consume(buffer_.size());
{
std::lock_guard<std::mutex> lock(messagesMutex_);
messages_.push_back(std::move(message));
}
read();
});
});
}
void write()
{
socket_.async_write(
asio::buffer(outbound_.front()),
[this](beast::error_code error, std::size_t) {
if (error) return fail("write", error);
outbound_.pop_front();
if (!outbound_.empty()) write();
});
withStream([this](auto& stream) {
stream.async_write(
asio::buffer(outbound_.front()),
[this](beast::error_code error, std::size_t) {
if (error) return fail("write", error);
outbound_.pop_front();
if (!outbound_.empty()) write();
});
});
}
void fail(const char* operation, beast::error_code error)
@@ -257,13 +432,20 @@ private:
}
asio::io_context io_;
#ifdef NETWORK_TLS
asio::ssl::context sslContext_;
#endif
tcp::resolver resolver_;
#ifdef NETWORK_TLS
websocket::stream<beast::ssl_stream<tcp::socket>> secureSocket_;
#endif
websocket::stream<tcp::socket> socket_;
beast::flat_buffer buffer_;
std::thread thread_;
std::string host_;
std::string port_;
std::string path_;
bool useTls_ = false;
std::atomic_bool connected_{false};
std::atomic_bool connectionFailed_{false};
std::mutex messagesMutex_;
@@ -273,9 +455,15 @@ private:
};
WebsocketGameClient::WebsocketGameClient(
std::string host, std::string port, std::string path)
std::string host, std::string port, std::string path, bool useTls)
: impl_(std::make_unique<Impl>(
std::move(host), std::move(port), std::move(path)))
std::move(host), std::move(port), std::move(path), useTls))
{
}
WebsocketGameClient::WebsocketGameClient(const WebsocketEndpoint& endpoint)
: WebsocketGameClient(
endpoint.host, endpoint.port, endpoint.path, endpoint.useTls)
{
}
+15 -1
View File
@@ -11,12 +11,26 @@ namespace FRG {
class RemoteGameServerHost;
// Parsed "ws://host[:port][/path]" or "wss://host[:port][/path]".
struct WebsocketEndpoint {
std::string host = "127.0.0.1";
std::string port = "8080";
std::string path = "/";
bool useTls = false;
// Returns false (and leaves *this untouched) for an unsupported/malformed URL.
// Default ports: 80 for ws://, 443 for wss://.
bool parse(const std::string& url);
};
class WebsocketGameClient final : public IGameServer {
public:
WebsocketGameClient(
std::string host = "127.0.0.1",
std::string port = "8080",
std::string path = "/");
std::string path = "/",
bool useTls = false);
explicit WebsocketGameClient(const WebsocketEndpoint& endpoint);
~WebsocketGameClient();
WebsocketGameClient(const WebsocketGameClient&) = delete;