Implemented the real server
This commit is contained in:
1 parent
931236bec0
commit
075750442f
22 files changed
+1130
-44
No files matched your search
@@ -0,0 +1,143 @@
|
|||||||
|
# WebSocket Networking: First Iteration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The first networking iteration adds a Windows-only WebSocket transport using
|
||||||
|
Boost.Asio and Boost.Beast.
|
||||||
|
|
||||||
|
The goal of this iteration is connection and replication stability. It does not
|
||||||
|
yet implement multiplayer commands, authentication, per-connection players,
|
||||||
|
state deltas, or presentation-event replication.
|
||||||
|
|
||||||
|
## Runtime Architecture
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
`WebsocketGameServer` wraps the headless `InternalGameServer`.
|
||||||
|
|
||||||
|
- It accepts any WebSocket connection.
|
||||||
|
- It sends the current complete game state immediately after connection.
|
||||||
|
- It broadcasts the current complete state every five seconds.
|
||||||
|
- Incoming WebSocket messages are read and discarded.
|
||||||
|
- The authoritative simulation remains on the main server thread.
|
||||||
|
- Network sessions only receive serialized state and never access
|
||||||
|
`InternalGameServer` directly.
|
||||||
|
|
||||||
|
The standalone server creates the existing fixed player ID `"player"` and does
|
||||||
|
not enable the battle-zone test bot.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
`WebsocketGameClient` implements `IGameServer`.
|
||||||
|
|
||||||
|
- It connects asynchronously to `ws://127.0.0.1:8080/`.
|
||||||
|
- 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
|
||||||
|
consumers and transitional runtime-state bindings continue to work.
|
||||||
|
- `submit()` intentionally discards all commands.
|
||||||
|
- `drainEvents()` returns no events.
|
||||||
|
- Client-side save and load calls are ignored because persistence remains
|
||||||
|
server-side.
|
||||||
|
|
||||||
|
`RemoteGameServerHost` prepares local presentation resources and runtime
|
||||||
|
locations against the replicated state. It does not advance authoritative
|
||||||
|
simulation or write Lua/dialogue auxiliary state back to the server.
|
||||||
|
|
||||||
|
## Campaign Routing
|
||||||
|
|
||||||
|
The graphical game uses stable router objects for `IGameServer` and
|
||||||
|
`IGameServerHost`, allowing existing UI and runtime references to remain valid.
|
||||||
|
|
||||||
|
When `NETWORK` is defined:
|
||||||
|
|
||||||
|
- `battle_zone` uses `WebsocketGameClient` and `RemoteGameServerHost`.
|
||||||
|
- Every other campaign uses `InternalGameServer` and `LocalGameServerHost`.
|
||||||
|
|
||||||
|
The WebSocket client is created only when `battle_zone` is selected and is
|
||||||
|
destroyed when that campaign is unloaded.
|
||||||
|
|
||||||
|
## JSON Protocol
|
||||||
|
|
||||||
|
Messages use a versioned JSON envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"type": "state",
|
||||||
|
"payload": {
|
||||||
|
"schemaVersion": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The payload temporarily uses the existing `GameState::save()` representation.
|
||||||
|
The client applies it through `InternalGameServer::load()`.
|
||||||
|
|
||||||
|
Malformed JSON, unsupported protocol versions, unsupported message types, and
|
||||||
|
invalid state payloads are logged and ignored.
|
||||||
|
|
||||||
|
This temporary full-state format is expected to be replaced or separated from
|
||||||
|
the persistence schema as command handling and production replication are
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
## Build Configuration
|
||||||
|
|
||||||
|
Networking is enabled only for Windows:
|
||||||
|
|
||||||
|
- `proj-windows/CMakeLists.txt` defines `NETWORK`, compiles the WebSocket client
|
||||||
|
and remote host, and links `ws2_32` and `mswsock`.
|
||||||
|
- `server/CMakeLists.txt` defines `NETWORK` for the Windows `GameServer`, compiles
|
||||||
|
the WebSocket server, and links the Windows socket libraries.
|
||||||
|
- Linux, Android, and Web builds do not define `NETWORK` and do not compile the
|
||||||
|
network transport.
|
||||||
|
|
||||||
|
The previous Web-build `NETWORK` definition was removed.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
Build the server:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake -S server -B server/build
|
||||||
|
cmake --build server/build --config Debug --target GameServer
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\server\build\Debug\GameServer.exe --campaign battle_zone --location uni_exterior
|
||||||
|
```
|
||||||
|
|
||||||
|
The listener defaults to `0.0.0.0:8080`. It can be changed with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--address IP
|
||||||
|
--port N
|
||||||
|
```
|
||||||
|
|
||||||
|
Launch the Windows game and select `battle_zone`. The client connects to
|
||||||
|
`127.0.0.1:8080`. Selecting another campaign continues to use the local server.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The implementation was verified with:
|
||||||
|
|
||||||
|
- A successful Windows graphical-game build.
|
||||||
|
- A successful Windows headless-server build.
|
||||||
|
- Server state and protocol tests.
|
||||||
|
- A WebSocket smoke test receiving the initial state and the next five-second
|
||||||
|
broadcast.
|
||||||
|
- A check that the shared `"player"` exists and the battle-zone test bot is not
|
||||||
|
created for the network server.
|
||||||
|
|
||||||
|
## Current Limitations
|
||||||
|
|
||||||
|
- All clients share the fixed player ID `"player"`.
|
||||||
|
- Client commands are not sent to the server.
|
||||||
|
- The server does not process commands.
|
||||||
|
- State is sent as a complete snapshot rather than a delta.
|
||||||
|
- There is no authentication, authorization, reconnect loop, or session identity.
|
||||||
|
- Presentation events are not replicated.
|
||||||
|
- The endpoint is hardcoded in the graphical client.
|
||||||
|
- Multiplayer Lua and dialogue behavior remains unsupported.
|
||||||
@@ -202,7 +202,6 @@ set(EMSCRIPTEN_FLAGS
|
|||||||
#"-pthread"
|
#"-pthread"
|
||||||
#"-sUSE_PTHREADS=1"
|
#"-sUSE_PTHREADS=1"
|
||||||
"-fexceptions"
|
"-fexceptions"
|
||||||
"-DNETWORK"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_options(ShadowOverBishkekDemo PRIVATE ${EMSCRIPTEN_FLAGS} "-O2")
|
target_compile_options(ShadowOverBishkekDemo PRIVATE ${EMSCRIPTEN_FLAGS} "-O2")
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ add_executable(ShadowOverBishkekDemo WIN32
|
|||||||
../src/game_server/IGameServer.h
|
../src/game_server/IGameServer.h
|
||||||
../src/game_server/IGameServerLifecycle.h
|
../src/game_server/IGameServerLifecycle.h
|
||||||
../src/game_server/IGameServerHost.h
|
../src/game_server/IGameServerHost.h
|
||||||
|
../src/game_server/GameServerRouter.h
|
||||||
|
../src/game_server/GameServerHostRouter.h
|
||||||
../src/game_server/InternalGameServer.h
|
../src/game_server/InternalGameServer.h
|
||||||
../src/game_server/InternalGameServer.cpp
|
../src/game_server/InternalGameServer.cpp
|
||||||
../src/game_server/LocalGameServerHost.h
|
../src/game_server/LocalGameServerHost.h
|
||||||
@@ -138,7 +140,9 @@ set_target_properties(ShadowOverBishkekDemo PROPERTIES
|
|||||||
# Определения препроцессора
|
# Определения препроцессора
|
||||||
target_compile_definitions(ShadowOverBishkekDemo PRIVATE
|
target_compile_definitions(ShadowOverBishkekDemo PRIVATE
|
||||||
WIN32_LEAN_AND_MEAN
|
WIN32_LEAN_AND_MEAN
|
||||||
|
_WIN32_WINNT=0x0A00
|
||||||
PNG_ENABLED
|
PNG_ENABLED
|
||||||
|
NETWORK
|
||||||
)
|
)
|
||||||
|
|
||||||
# Линкуем с SDL2main, если он вообще установлен
|
# Линкуем с SDL2main, если он вообще установлен
|
||||||
@@ -160,7 +164,16 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE
|
|||||||
|
|
||||||
# Линкуем OpenGL (Windows)
|
# Линкуем OpenGL (Windows)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(ShadowOverBishkekDemo PRIVATE opengl32)
|
target_sources(ShadowOverBishkekDemo PRIVATE
|
||||||
|
../src/game_server/NetworkProtocol.h
|
||||||
|
../src/game_server/NetworkProtocol.cpp
|
||||||
|
../src/game_server/WebsocketGameClient.h
|
||||||
|
../src/game_server/WebsocketGameClient.cpp
|
||||||
|
../src/game_server/RemoteGameServerHost.h
|
||||||
|
../src/game_server/RemoteGameServerHost.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(
|
||||||
|
ShadowOverBishkekDemo PRIVATE opengl32 ws2_32 mswsock)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
+31
-2
@@ -105,6 +105,23 @@ endif()
|
|||||||
add_executable(GameServer src/main.cpp README.md)
|
add_executable(GameServer src/main.cpp README.md)
|
||||||
source_group("Server" FILES src/main.cpp README.md)
|
source_group("Server" FILES src/main.cpp README.md)
|
||||||
target_link_libraries(GameServer PRIVATE game_server_core)
|
target_link_libraries(GameServer PRIVATE game_server_core)
|
||||||
|
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)
|
||||||
|
target_link_libraries(GameServer PRIVATE ws2_32 mswsock)
|
||||||
|
endif()
|
||||||
|
|
||||||
include(CTest)
|
include(CTest)
|
||||||
if(BUILD_TESTING)
|
if(BUILD_TESTING)
|
||||||
@@ -116,14 +133,26 @@ if(BUILD_TESTING)
|
|||||||
add_test(
|
add_test(
|
||||||
NAME GameServer.LocationState
|
NAME GameServer.LocationState
|
||||||
COMMAND GameServerStateTests)
|
COMMAND GameServerStateTests)
|
||||||
|
if(WIN32)
|
||||||
|
add_executable(
|
||||||
|
GameServerNetworkProtocolTests
|
||||||
|
tests/NetworkProtocolTests.cpp
|
||||||
|
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
|
||||||
|
"${GAME_ROOT}/src/game_server/NetworkProtocol.h")
|
||||||
|
target_link_libraries(
|
||||||
|
GameServerNetworkProtocolTests PRIVATE game_server_core)
|
||||||
|
add_test(
|
||||||
|
NAME GameServer.NetworkProtocol
|
||||||
|
COMMAND GameServerNetworkProtocolTests)
|
||||||
|
endif()
|
||||||
add_test(
|
add_test(
|
||||||
NAME GameServer.IdleSmoke
|
NAME GameServer.IdleSmoke
|
||||||
COMMAND GameServer --ticks 2 --no-sleep)
|
COMMAND GameServer --ticks 2 --no-sleep)
|
||||||
add_test(
|
add_test(
|
||||||
NAME GameServer.BattleZoneZeroPlayers
|
NAME GameServer.BattleZoneSharedPlayer
|
||||||
COMMAND GameServer --campaign battle_zone --location uni_exterior
|
COMMAND GameServer --campaign battle_zone --location uni_exterior
|
||||||
--ticks 2 --no-sleep)
|
--ticks 2 --no-sleep)
|
||||||
set_tests_properties(
|
set_tests_properties(
|
||||||
GameServer.BattleZoneZeroPlayers
|
GameServer.BattleZoneSharedPlayer
|
||||||
PROPERTIES WORKING_DIRECTORY "${GAME_ROOT}")
|
PROPERTIES WORKING_DIRECTORY "${GAME_ROOT}")
|
||||||
endif()
|
endif()
|
||||||
+16
-7
@@ -1,8 +1,8 @@
|
|||||||
# Headless GameServer
|
# Headless GameServer
|
||||||
|
|
||||||
This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
|
This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
|
||||||
audio, Lua, or rendering dependencies. It can prepare campaign/location data
|
audio, Lua, or rendering dependencies. On Windows it exposes the state through
|
||||||
headlessly; networking is not implemented yet.
|
a Boost.Beast WebSocket listener.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -39,12 +39,18 @@ For a multi-configuration Windows build, the executable is normally
|
|||||||
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
|
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
|
||||||
real-time delay.
|
real-time delay.
|
||||||
|
|
||||||
Load a location with no players:
|
Start the first multiplayer transport iteration:
|
||||||
|
|
||||||
```bash
|
```powershell
|
||||||
./server/build/GameServer --campaign battle_zone --location uni_exterior
|
.\server\build\Release\GameServer.exe --campaign battle_zone --location uni_exterior
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game
|
||||||
|
afterward and select `battle_zone`; that campaign connects to
|
||||||
|
`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server.
|
||||||
|
The server broadcasts an initial full state and then one every five seconds.
|
||||||
|
Client commands are intentionally discarded in this iteration.
|
||||||
|
|
||||||
If `--location` is omitted, the campaign's configured start location is used.
|
If `--location` is omitted, the campaign's configured start location is used.
|
||||||
`--campaigns PATH` selects a different campaign registry. Content paths are
|
`--campaigns PATH` selects a different campaign registry. Content paths are
|
||||||
resolved from the process working directory, so run from the repository root
|
resolved from the process working directory, so run from the repository root
|
||||||
@@ -56,6 +62,8 @@ Available startup options:
|
|||||||
--campaign ID
|
--campaign ID
|
||||||
--location ID
|
--location ID
|
||||||
--campaigns PATH
|
--campaigns PATH
|
||||||
|
--address IP
|
||||||
|
--port N
|
||||||
--tick-ms N
|
--tick-ms N
|
||||||
--ticks N
|
--ticks N
|
||||||
--no-sleep
|
--no-sleep
|
||||||
@@ -64,8 +72,9 @@ Available startup options:
|
|||||||
The graphical client executes Lua/dialogue behavior after building its runtime
|
The graphical client executes Lua/dialogue behavior after building its runtime
|
||||||
`Location`; the headless content pass loads authoritative navigation, NPC,
|
`Location`; the headless content pass loads authoritative navigation, NPC,
|
||||||
interactive-object, trigger, teleport, taxi, and animation metadata only.
|
interactive-object, trigger, teleport, taxi, and animation metadata only.
|
||||||
The standalone lifecycle intentionally creates neither the local `"player"` nor
|
The standalone WebSocket lifecycle creates the existing shared `"player"` so
|
||||||
the battle-zone test bot. Those are opt-ins used by the graphical host.
|
the graphical client can render its local character. It does not create the
|
||||||
|
battle-zone test bot.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
+39
-5
@@ -1,4 +1,7 @@
|
|||||||
#include "game_server/InternalGameServer.h"
|
#include "game_server/InternalGameServer.h"
|
||||||
|
#if defined(NETWORK)
|
||||||
|
#include "game_server/WebsocketGameServer.h"
|
||||||
|
#endif
|
||||||
#include "Campaign.h"
|
#include "Campaign.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -7,6 +10,7 @@
|
|||||||
#include <exception>
|
#include <exception>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
@@ -38,12 +42,14 @@ std::int64_t parsePositiveNumber(const char* value, const char* option)
|
|||||||
|
|
||||||
int main(int argc, char** argv)
|
int main(int argc, char** argv)
|
||||||
{
|
{
|
||||||
std::int64_t tickMs = 50;
|
std::int64_t tickMs = 500;
|
||||||
std::int64_t maxTicks = (std::numeric_limits<std::int64_t>::max)();
|
std::int64_t maxTicks = (std::numeric_limits<std::int64_t>::max)();
|
||||||
bool sleepBetweenTicks = true;
|
bool sleepBetweenTicks = true;
|
||||||
std::string campaignId;
|
std::string campaignId;
|
||||||
std::string locationId;
|
std::string locationId;
|
||||||
std::string campaignsPath = "resources/config/campaigns.json";
|
std::string campaignsPath = "resources/config/campaigns.json";
|
||||||
|
std::string listenAddress = "0.0.0.0";
|
||||||
|
std::int64_t listenPort = 8080;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (int index = 1; index < argc; ++index) {
|
for (int index = 1; index < argc; ++index) {
|
||||||
@@ -60,11 +66,17 @@ int main(int argc, char** argv)
|
|||||||
locationId = argv[++index];
|
locationId = argv[++index];
|
||||||
} else if (option == "--campaigns" && index + 1 < argc) {
|
} else if (option == "--campaigns" && index + 1 < argc) {
|
||||||
campaignsPath = argv[++index];
|
campaignsPath = argv[++index];
|
||||||
|
} else if (option == "--address" && index + 1 < argc) {
|
||||||
|
listenAddress = argv[++index];
|
||||||
|
} else if (option == "--port" && index + 1 < argc) {
|
||||||
|
listenPort = parsePositiveNumber(argv[++index], "--port");
|
||||||
|
if (listenPort > 65535)
|
||||||
|
throw std::runtime_error("Invalid value for --port");
|
||||||
} else if (option == "--help") {
|
} else if (option == "--help") {
|
||||||
std::cout
|
std::cout
|
||||||
<< "Usage: GameServer [--ticks N] [--tick-ms N] "
|
<< "Usage: GameServer [--ticks N] [--tick-ms N] "
|
||||||
"[--no-sleep] [--campaign ID] [--location ID] "
|
"[--no-sleep] [--campaign ID] [--location ID] "
|
||||||
"[--campaigns PATH]\n";
|
"[--campaigns PATH] [--address IP] [--port N]\n";
|
||||||
return EXIT_SUCCESS;
|
return EXIT_SUCCESS;
|
||||||
} else {
|
} else {
|
||||||
throw std::runtime_error("Unknown or incomplete option: " + option);
|
throw std::runtime_error("Unknown or incomplete option: " + option);
|
||||||
@@ -75,6 +87,10 @@ int main(int argc, char** argv)
|
|||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Temporary override:
|
||||||
|
campaignId = "battle_zone";
|
||||||
|
locationId = "uni_exterior";
|
||||||
|
|
||||||
std::signal(SIGINT, requestStop);
|
std::signal(SIGINT, requestStop);
|
||||||
std::signal(SIGTERM, requestStop);
|
std::signal(SIGTERM, requestStop);
|
||||||
|
|
||||||
@@ -95,12 +111,12 @@ int main(int argc, char** argv)
|
|||||||
throw std::runtime_error(
|
throw std::runtime_error(
|
||||||
"Campaign '" + campaignId +
|
"Campaign '" + campaignId +
|
||||||
"' has no location '" + selectedLocation + "'");
|
"' has no location '" + selectedLocation + "'");
|
||||||
server.startCampaign(*campaign, std::string(), false, false);
|
server.startCampaign(*campaign, std::string(), true, false);
|
||||||
server.prepareLocation(*location);
|
server.prepareLocation(*location);
|
||||||
server.activateLocation(selectedLocation);
|
server.activateLocation(selectedLocation);
|
||||||
std::cout << "Loaded campaign '" << campaignId
|
std::cout << "Loaded campaign '" << campaignId
|
||||||
<< "', location '" << selectedLocation
|
<< "', location '" << selectedLocation
|
||||||
<< "' with zero players\n";
|
<< "' with shared player '" << server.localPlayerId() << "'\n";
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << "Server content setup failed: "
|
std::cerr << "Server content setup failed: "
|
||||||
<< error.what() << '\n';
|
<< error.what() << '\n';
|
||||||
@@ -110,12 +126,30 @@ int main(int argc, char** argv)
|
|||||||
const auto tickDuration = std::chrono::milliseconds(tickMs);
|
const auto tickDuration = std::chrono::milliseconds(tickMs);
|
||||||
auto nextTick = std::chrono::steady_clock::now();
|
auto nextTick = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
std::cout << "GameServer started (idle headless mode, "
|
#if defined(NETWORK)
|
||||||
|
std::unique_ptr<FRG::WebsocketGameServer> websocketServer;
|
||||||
|
try {
|
||||||
|
websocketServer = std::make_unique<FRG::WebsocketGameServer>(
|
||||||
|
server, listenAddress, static_cast<std::uint16_t>(listenPort));
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
std::cerr << "WebSocket listener setup failed: "
|
||||||
|
<< error.what() << '\n';
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
std::cout << "GameServer listening on ws://" << listenAddress << ':'
|
||||||
|
<< websocketServer->port() << " (" << tickMs << " ms tick)\n";
|
||||||
|
#else
|
||||||
|
std::cout << "GameServer started (network disabled, "
|
||||||
<< tickMs << " ms tick)\n";
|
<< tickMs << " ms tick)\n";
|
||||||
|
#endif
|
||||||
for (std::int64_t tick = 0;
|
for (std::int64_t tick = 0;
|
||||||
tick < maxTicks && !stopRequested.load(); ++tick) {
|
tick < maxTicks && !stopRequested.load(); ++tick) {
|
||||||
nextTick += tickDuration;
|
nextTick += tickDuration;
|
||||||
|
#if defined(NETWORK)
|
||||||
|
websocketServer->update(tickMs);
|
||||||
|
#else
|
||||||
server.update(tickMs);
|
server.update(tickMs);
|
||||||
|
#endif
|
||||||
if (sleepBetweenTicks) std::this_thread::sleep_until(nextTick);
|
if (sleepBetweenTicks) std::this_thread::sleep_until(nextTick);
|
||||||
}
|
}
|
||||||
std::cout << "GameServer stopped\n";
|
std::cout << "GameServer stopped\n";
|
||||||
|
|||||||
@@ -86,5 +86,13 @@ int main()
|
|||||||
return fail("Battle Zone test bot animation metadata was not loaded");
|
return fail("Battle Zone test bot animation metadata was not loaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FRG::InternalGameServer networkServer;
|
||||||
|
networkServer.startCampaign(battleZone, {}, true, false);
|
||||||
|
const auto networkSnapshot = networkServer.snapshot();
|
||||||
|
if (networkSnapshot->players.count("player") != 1)
|
||||||
|
return fail("Network campaign shared player was not created");
|
||||||
|
if (networkSnapshot->players.count("battle_zone_test_bot") != 0)
|
||||||
|
return fail("Network campaign unexpectedly created the test bot");
|
||||||
|
|
||||||
return EXIT_SUCCESS;
|
return EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#include "game_server/NetworkProtocol.h"
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void require(bool condition, const char* message)
|
||||||
|
{
|
||||||
|
if (condition) return;
|
||||||
|
std::cerr << "NetworkProtocolTests: " << message << '\n';
|
||||||
|
std::exit(EXIT_FAILURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
const nlohmann::json original{
|
||||||
|
{"schemaVersion", 1},
|
||||||
|
{"campaignId", "battle_zone"},
|
||||||
|
{"players", {{"player", {{"hp", 200.0}}}}}
|
||||||
|
};
|
||||||
|
const std::string message = FRG::makeStateMessage(original);
|
||||||
|
nlohmann::json parsed;
|
||||||
|
std::string error;
|
||||||
|
require(
|
||||||
|
FRG::parseStateMessage(message, parsed, error),
|
||||||
|
"valid state envelope was rejected");
|
||||||
|
require(parsed == original, "state payload did not round trip");
|
||||||
|
|
||||||
|
require(
|
||||||
|
!FRG::parseStateMessage(
|
||||||
|
R"({"protocolVersion":2,"type":"state","payload":{}})",
|
||||||
|
parsed, error),
|
||||||
|
"unknown protocol version was accepted");
|
||||||
|
require(
|
||||||
|
!FRG::parseStateMessage(
|
||||||
|
R"({"protocolVersion":1,"type":"command","payload":{}})",
|
||||||
|
parsed, error),
|
||||||
|
"unknown message type was accepted");
|
||||||
|
require(
|
||||||
|
!FRG::parseStateMessage("not json", parsed, error),
|
||||||
|
"malformed JSON was accepted");
|
||||||
|
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
+48
-24
@@ -85,8 +85,10 @@ namespace FRG
|
|||||||
Game::Game()
|
Game::Game()
|
||||||
: audioPlayer(std::make_unique<AudioPlayerAsync>())
|
: audioPlayer(std::make_unique<AudioPlayerAsync>())
|
||||||
, internalGameServer()
|
, internalGameServer()
|
||||||
, gameServerHost(internalGameServer)
|
, localGameServerHost(internalGameServer)
|
||||||
, menuManager(renderer, internalGameServer, *audioPlayer)
|
, gameServer(internalGameServer)
|
||||||
|
, gameServerHost(localGameServerHost)
|
||||||
|
, menuManager(renderer, gameServer, *audioPlayer)
|
||||||
, newTickCount(0)
|
, newTickCount(0)
|
||||||
, lastTickCount(0)
|
, lastTickCount(0)
|
||||||
{
|
{
|
||||||
@@ -400,7 +402,7 @@ namespace FRG
|
|||||||
performResetToInitialState();
|
performResetToInitialState();
|
||||||
applyTutorialSetting();
|
applyTutorialSetting();
|
||||||
const CampaignConfig& campaign = activeCampaign();
|
const CampaignConfig& campaign = activeCampaign();
|
||||||
internalGameServer.submit(
|
server().submit(
|
||||||
SetCurrentLocationCommand{campaign.startLocation});
|
SetCurrentLocationCommand{campaign.startLocation});
|
||||||
menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state
|
menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state
|
||||||
if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback();
|
if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback();
|
||||||
@@ -549,12 +551,27 @@ namespace FRG
|
|||||||
const CampaignConfig* campaign =
|
const CampaignConfig* campaign =
|
||||||
CampaignRegistry::instance().find(campaignId);
|
CampaignRegistry::instance().find(campaignId);
|
||||||
if (!campaign) return;
|
if (!campaign) return;
|
||||||
|
#if defined(NETWORK)
|
||||||
|
remoteCampaignActive = campaignId == "battle_zone";
|
||||||
|
if (remoteCampaignActive) {
|
||||||
|
websocketGameClient = std::make_unique<WebsocketGameClient>();
|
||||||
|
remoteGameServerHost =
|
||||||
|
std::make_unique<RemoteGameServerHost>(*websocketGameClient);
|
||||||
|
gameServer.use(*websocketGameClient);
|
||||||
|
gameServerHost.use(*remoteGameServerHost);
|
||||||
|
} else {
|
||||||
|
gameServer.use(internalGameServer);
|
||||||
|
gameServerHost.use(localGameServerHost);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
remoteCampaignActive = false;
|
||||||
|
#endif
|
||||||
gameServerHost.initializeCampaign(
|
gameServerHost.initializeCampaign(
|
||||||
campaignId, campaign->questsPath, CONST_ZIP_FILE);
|
campaignId, campaign->questsPath, CONST_ZIP_FILE);
|
||||||
// The player stays loaded for the whole campaign; locations come and go around him.
|
// The player stays loaded for the whole campaign; locations come and go around him.
|
||||||
player = Location::createPlayer(
|
player = Location::createPlayer(
|
||||||
renderer, audioPlayer.get(), internalGameServer,
|
renderer, audioPlayer.get(), server(),
|
||||||
internalGameServer.localPlayerId());
|
server().localPlayerId());
|
||||||
if (player)
|
if (player)
|
||||||
player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
|
player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
|
||||||
}
|
}
|
||||||
@@ -564,6 +581,13 @@ namespace FRG
|
|||||||
player.reset();
|
player.reset();
|
||||||
loadedCampaignId.clear();
|
loadedCampaignId.clear();
|
||||||
gameServerHost.clearCampaign();
|
gameServerHost.clearCampaign();
|
||||||
|
#if defined(NETWORK)
|
||||||
|
gameServer.use(internalGameServer);
|
||||||
|
gameServerHost.use(localGameServerHost);
|
||||||
|
remoteGameServerHost.reset();
|
||||||
|
websocketGameClient.reset();
|
||||||
|
#endif
|
||||||
|
remoteCampaignActive = false;
|
||||||
renderer.textureManager.UnloadUnused(); // the player's textures
|
renderer.textureManager.UnloadUnused(); // the player's textures
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +633,7 @@ namespace FRG
|
|||||||
const auto state = stateSnapshot();
|
const auto state = stateSnapshot();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
enterLocationThen(state->campaignId, destName, [this, destName, destPos, destRotY]() {
|
enterLocationThen(state->campaignId, destName, [this, destName, destPos, destRotY]() {
|
||||||
internalGameServer.submit(SetCurrentLocationCommand{destName});
|
server().submit(SetCurrentLocationCommand{destName});
|
||||||
Location* loc = currentLocation();
|
Location* loc = currentLocation();
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
if (loc->player) {
|
if (loc->player) {
|
||||||
@@ -624,13 +648,13 @@ namespace FRG
|
|||||||
const float inclination = locationState
|
const float inclination = locationState
|
||||||
? locationState->cameraInclination
|
? locationState->cameraInclination
|
||||||
: 1.1036f;
|
: 1.1036f;
|
||||||
internalGameServer.submit(SetLocationCameraCommand{
|
server().submit(SetLocationCameraCommand{
|
||||||
destName, destRotY, inclination});
|
destName, destRotY, inclination});
|
||||||
loc->scriptEngine.callLocationEnterCallback();
|
loc->scriptEngine.callLocationEnterCallback();
|
||||||
|
|
||||||
//Reset taxi
|
//Reset taxi
|
||||||
internalGameServer.submit(ResetTaxiCommand{destName});
|
server().submit(ResetTaxiCommand{destName});
|
||||||
internalGameServer.submit(SetTaxiCalledCommand{false});
|
server().submit(SetTaxiCalledCommand{false});
|
||||||
|
|
||||||
menuManager.onLocationChanged(destName);
|
menuManager.onLocationChanged(destName);
|
||||||
});
|
});
|
||||||
@@ -693,7 +717,7 @@ namespace FRG
|
|||||||
// (if the campaign has one); a disabled tutorial is simply one that is already completed.
|
// (if the campaign has one); a disabled tutorial is simply one that is already completed.
|
||||||
void Game::applyTutorialSetting() {
|
void Game::applyTutorialSetting() {
|
||||||
if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting)
|
if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting)
|
||||||
internalGameServer.submit(
|
server().submit(
|
||||||
SetTutorialStepCommand{TutorialStep::Completed});
|
SetTutorialStepCommand{TutorialStep::Completed});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,20 +755,20 @@ namespace FRG
|
|||||||
params.player = player;
|
params.player = player;
|
||||||
|
|
||||||
auto loc = std::make_shared<Location>(
|
auto loc = std::make_shared<Location>(
|
||||||
renderer, internalGameServer, config.id);
|
renderer, server(), config.id);
|
||||||
gameServerHost.setupRuntimeLocation(
|
gameServerHost.setupRuntimeLocation(
|
||||||
config.id, loc, std::move(params), audioPlayer.get());
|
config.id, loc, std::move(params), audioPlayer.get());
|
||||||
loc->requestNightDayTransition = [this](bool isNight, bool isDawn) {
|
loc->requestNightDayTransition = [this](bool isNight, bool isDawn) {
|
||||||
const auto state = stateSnapshot();
|
const auto state = stateSnapshot();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
internalGameServer.submit(SetWorldModeCommand{
|
server().submit(SetWorldModeCommand{
|
||||||
state->isDarklands, isNight, isDawn});
|
state->isDarklands, isNight, isDawn});
|
||||||
};
|
};
|
||||||
loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); };
|
loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); };
|
||||||
loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); };
|
loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); };
|
||||||
loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); };
|
loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); };
|
||||||
loc->requestReturnToMainMenu = [this]() {
|
loc->requestReturnToMainMenu = [this]() {
|
||||||
internalGameServer.submit(SetCurrentLocationCommand{std::string()});
|
server().submit(SetCurrentLocationCommand{std::string()});
|
||||||
menuManager.showMenuAfterGameCompleted();
|
menuManager.showMenuAfterGameCompleted();
|
||||||
};
|
};
|
||||||
loc->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) {
|
loc->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) {
|
||||||
@@ -783,11 +807,11 @@ namespace FRG
|
|||||||
// completeTutorialStep() only acts on the current step, so sharing is safe.
|
// completeTutorialStep() only acts on the current step, so sharing is safe.
|
||||||
{
|
{
|
||||||
loc->dialogueSystem.setOnDialogueAdvanced([this]() {
|
loc->dialogueSystem.setOnDialogueAdvanced([this]() {
|
||||||
internalGameServer.submit(
|
server().submit(
|
||||||
CompleteTutorialStepCommand{TutorialStep::AdvanceDialogue});
|
CompleteTutorialStepCommand{TutorialStep::AdvanceDialogue});
|
||||||
});
|
});
|
||||||
loc->onPlayerFloorWalk = [this]() {
|
loc->onPlayerFloorWalk = [this]() {
|
||||||
internalGameServer.submit(
|
server().submit(
|
||||||
CompleteTutorialStepCommand{TutorialStep::Walk});
|
CompleteTutorialStepCommand{TutorialStep::Walk});
|
||||||
menuManager.onPlayerStartedWalking();
|
menuManager.onPlayerStartedWalking();
|
||||||
};
|
};
|
||||||
@@ -840,9 +864,9 @@ namespace FRG
|
|||||||
loc->player->isMoving() == false
|
loc->player->isMoving() == false
|
||||||
&&
|
&&
|
||||||
loc->player->getHp() > 0) {
|
loc->player->getHp() > 0) {
|
||||||
internalGameServer.submit(SetCharacterTargetCommand{
|
server().submit(SetCharacterTargetCommand{
|
||||||
loc->locationId(),
|
loc->locationId(),
|
||||||
internalGameServer.localPlayerId(), attackerId});
|
server().localPlayerId(), attackerId});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1134,7 +1158,7 @@ namespace FRG
|
|||||||
if (!menuManager.isMainMenuOpen())
|
if (!menuManager.isMainMenuOpen())
|
||||||
menuManager.update(static_cast<float>(delta));
|
menuManager.update(static_cast<float>(delta));
|
||||||
|
|
||||||
if (loadingCompleted && currentLocation())
|
if (loadingCompleted)
|
||||||
{
|
{
|
||||||
// The authoritative implementation advances simulation; a future
|
// The authoritative implementation advances simulation; a future
|
||||||
// network client can use the same call to poll/apply server updates.
|
// network client can use the same call to poll/apply server updates.
|
||||||
@@ -1147,7 +1171,7 @@ namespace FRG
|
|||||||
|
|
||||||
void Game::processServerEvents()
|
void Game::processServerEvents()
|
||||||
{
|
{
|
||||||
for (GameServerEvent& event : internalGameServer.drainEvents()) {
|
for (GameServerEvent& event : server().drainEvents()) {
|
||||||
std::visit([this](const auto& value) {
|
std::visit([this](const auto& value) {
|
||||||
using T = std::decay_t<decltype(value)>;
|
using T = std::decay_t<decltype(value)>;
|
||||||
if constexpr (std::is_same_v<T, InventoryItemAddedEvent>) {
|
if constexpr (std::is_same_v<T, InventoryItemAddedEvent>) {
|
||||||
@@ -1380,7 +1404,7 @@ namespace FRG
|
|||||||
}
|
}
|
||||||
//logger() << "Current zoom: " << Environment::zoom << std::endl;
|
//logger() << "Current zoom: " << Environment::zoom << std::endl;
|
||||||
// Tutorial: any mouse-wheel scroll counts as the "zoom gesture".
|
// Tutorial: any mouse-wheel scroll counts as the "zoom gesture".
|
||||||
internalGameServer.submit(
|
server().submit(
|
||||||
CompleteTutorialStepCommand{TutorialStep::Zoom});
|
CompleteTutorialStepCommand{TutorialStep::Zoom});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,7 +1588,7 @@ namespace FRG
|
|||||||
|
|
||||||
// Tutorial: detect a significant pinch-zoom (≥ 2 zoom units).
|
// Tutorial: detect a significant pinch-zoom (≥ 2 zoom units).
|
||||||
if (std::abs(Environment::zoom - pinchStartZoom) >= 2.0f) {
|
if (std::abs(Environment::zoom - pinchStartZoom) >= 2.0f) {
|
||||||
internalGameServer.submit(
|
server().submit(
|
||||||
CompleteTutorialStepCommand{TutorialStep::Zoom});
|
CompleteTutorialStepCommand{TutorialStep::Zoom});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1717,7 +1741,7 @@ namespace FRG
|
|||||||
float deltaAz = std::abs(state->cameraAzimuth - dragStartAzimuth);
|
float deltaAz = std::abs(state->cameraAzimuth - dragStartAzimuth);
|
||||||
float deltaInc = std::abs(state->cameraInclination - dragStartInclination);
|
float deltaInc = std::abs(state->cameraInclination - dragStartInclination);
|
||||||
if (deltaAz >= TUTORIAL_ROTATION_THRESHOLD && deltaInc >= TUTORIAL_ROTATION_THRESHOLD) {
|
if (deltaAz >= TUTORIAL_ROTATION_THRESHOLD && deltaInc >= TUTORIAL_ROTATION_THRESHOLD) {
|
||||||
internalGameServer.submit(
|
server().submit(
|
||||||
CompleteTutorialStepCommand{TutorialStep::RotateCamera});
|
CompleteTutorialStepCommand{TutorialStep::RotateCamera});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1770,7 +1794,7 @@ namespace FRG
|
|||||||
const auto before = stateSnapshot();
|
const auto before = stateSnapshot();
|
||||||
if (!before) return;
|
if (!before) return;
|
||||||
if (isNightTransition) {
|
if (isNightTransition) {
|
||||||
internalGameServer.submit(SetWorldModeCommand{
|
server().submit(SetWorldModeCommand{
|
||||||
before->isDarklands, !before->isNight, before->isDawn});
|
before->isDarklands, !before->isNight, before->isDawn});
|
||||||
if (currentLocation())
|
if (currentLocation())
|
||||||
{
|
{
|
||||||
@@ -1781,7 +1805,7 @@ namespace FRG
|
|||||||
menuManager.lastMusicTrack = "music/bishkek night.ogg";
|
menuManager.lastMusicTrack = "music/bishkek night.ogg";
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
internalGameServer.submit(SetWorldModeCommand{
|
server().submit(SetWorldModeCommand{
|
||||||
!before->isDarklands, before->isNight, before->isDawn});
|
!before->isDarklands, before->isNight, before->isDawn});
|
||||||
const auto after = stateSnapshot();
|
const auto after = stateSnapshot();
|
||||||
if (!after) return;
|
if (!after) return;
|
||||||
|
|||||||
+17
-4
@@ -23,6 +23,12 @@
|
|||||||
#include "AudioPlayerAsync.h"
|
#include "AudioPlayerAsync.h"
|
||||||
#include "game_server/InternalGameServer.h"
|
#include "game_server/InternalGameServer.h"
|
||||||
#include "game_server/LocalGameServerHost.h"
|
#include "game_server/LocalGameServerHost.h"
|
||||||
|
#include "game_server/GameServerRouter.h"
|
||||||
|
#include "game_server/GameServerHostRouter.h"
|
||||||
|
#if defined(NETWORK)
|
||||||
|
#include "game_server/WebsocketGameClient.h"
|
||||||
|
#include "game_server/RemoteGameServerHost.h"
|
||||||
|
#endif
|
||||||
namespace FRG {
|
namespace FRG {
|
||||||
|
|
||||||
struct LocationConfig;
|
struct LocationConfig;
|
||||||
@@ -84,11 +90,18 @@ namespace FRG {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
InternalGameServer internalGameServer;
|
InternalGameServer internalGameServer;
|
||||||
LocalGameServerHost gameServerHost;
|
LocalGameServerHost localGameServerHost;
|
||||||
|
#if defined(NETWORK)
|
||||||
|
std::unique_ptr<WebsocketGameClient> websocketGameClient;
|
||||||
|
std::unique_ptr<RemoteGameServerHost> remoteGameServerHost;
|
||||||
|
#endif
|
||||||
|
GameServerRouter gameServer;
|
||||||
|
GameServerHostRouter gameServerHost;
|
||||||
|
bool remoteCampaignActive = false;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
IGameServer& server() { return internalGameServer; }
|
IGameServer& server() { return gameServer; }
|
||||||
const IGameServer& server() const { return internalGameServer; }
|
const IGameServer& server() const { return gameServer; }
|
||||||
|
|
||||||
MenuManager menuManager;
|
MenuManager menuManager;
|
||||||
|
|
||||||
@@ -131,7 +144,7 @@ namespace FRG {
|
|||||||
|
|
||||||
Location* currentLocation() const;
|
Location* currentLocation() const;
|
||||||
std::shared_ptr<const GameSnapshot> stateSnapshot() const {
|
std::shared_ptr<const GameSnapshot> stateSnapshot() const {
|
||||||
return internalGameServer.snapshot();
|
return gameServer.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
void saveGame(int slot);
|
void saveGame(int slot);
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ namespace FRG
|
|||||||
friend class Game;
|
friend class Game;
|
||||||
friend class ScriptEngine;
|
friend class ScriptEngine;
|
||||||
friend class LocalGameServerHost;
|
friend class LocalGameServerHost;
|
||||||
|
friend class RemoteGameServerHost;
|
||||||
Renderer& renderer;
|
Renderer& renderer;
|
||||||
AudioPlayerAsync* audioPlayer;
|
AudioPlayerAsync* audioPlayer;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "IGameServerHost.h"
|
||||||
|
#include "../Location.h"
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
|
||||||
|
class GameServerHostRouter final : public IGameServerHost {
|
||||||
|
public:
|
||||||
|
explicit GameServerHostRouter(IGameServerHost& initial)
|
||||||
|
: active_(&initial)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void use(IGameServerHost& host) { active_ = &host; }
|
||||||
|
void initializeCampaign(
|
||||||
|
const std::string& campaignId, const std::string& questsPath,
|
||||||
|
const std::string& zipFile) override {
|
||||||
|
active_->initializeCampaign(campaignId, questsPath, zipFile);
|
||||||
|
}
|
||||||
|
void clearCampaign() override { active_->clearCampaign(); }
|
||||||
|
void resetCampaign(
|
||||||
|
const std::string& questsPath, const std::string& zipFile) override {
|
||||||
|
active_->resetCampaign(questsPath, zipFile);
|
||||||
|
}
|
||||||
|
bool prepareLocation(const LocationConfig& config) override {
|
||||||
|
return active_->prepareLocation(config);
|
||||||
|
}
|
||||||
|
void setupRuntimeLocation(
|
||||||
|
const std::string& id, const std::shared_ptr<Location>& location,
|
||||||
|
LocationSetup params, AudioPlayerAsync* audioPlayer) override {
|
||||||
|
active_->setupRuntimeLocation(
|
||||||
|
id, location, std::move(params), audioPlayer);
|
||||||
|
}
|
||||||
|
Location* runtimeLocation(const std::string& id) const override {
|
||||||
|
return active_->runtimeLocation(id);
|
||||||
|
}
|
||||||
|
bool hasRuntimeLocation(const std::string& id) const override {
|
||||||
|
return active_->hasRuntimeLocation(id);
|
||||||
|
}
|
||||||
|
std::vector<std::string> loadedRuntimeLocationIds() const override {
|
||||||
|
return active_->loadedRuntimeLocationIds();
|
||||||
|
}
|
||||||
|
void clearRuntimeLocations(bool retainState) override {
|
||||||
|
active_->clearRuntimeLocations(retainState);
|
||||||
|
}
|
||||||
|
void bindRuntimeLocation(
|
||||||
|
const std::string& id, std::shared_ptr<Location> location) override {
|
||||||
|
active_->bindRuntimeLocation(id, std::move(location));
|
||||||
|
}
|
||||||
|
void update(std::int64_t deltaMs) override { active_->update(deltaMs); }
|
||||||
|
void save(nlohmann::json& out) override { active_->save(out); }
|
||||||
|
void load(const nlohmann::json& in) override { active_->load(in); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
IGameServerHost* active_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "IGameServer.h"
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
|
||||||
|
// Keeps references held by UI and runtime objects stable while campaigns switch
|
||||||
|
// between the local and remote implementations.
|
||||||
|
class GameServerRouter final : public IGameServer {
|
||||||
|
public:
|
||||||
|
explicit GameServerRouter(IGameServer& initial)
|
||||||
|
: active_(&initial)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void use(IGameServer& server) { active_ = &server; }
|
||||||
|
std::shared_ptr<const GameSnapshot> snapshot() const override {
|
||||||
|
return active_->snapshot();
|
||||||
|
}
|
||||||
|
const std::string& localPlayerId() const override {
|
||||||
|
return active_->localPlayerId();
|
||||||
|
}
|
||||||
|
void submit(const GameCommand& command) override { active_->submit(command); }
|
||||||
|
std::vector<GameServerEvent> drainEvents() override {
|
||||||
|
return active_->drainEvents();
|
||||||
|
}
|
||||||
|
void update(std::int64_t deltaMs) override { active_->update(deltaMs); }
|
||||||
|
void save(nlohmann::json& out) const override { active_->save(out); }
|
||||||
|
void load(const nlohmann::json& in) override { active_->load(in); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
IGameServer* active_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
namespace FRG {
|
namespace FRG {
|
||||||
|
|
||||||
class LocalGameServerHost;
|
class LocalGameServerHost;
|
||||||
|
class RemoteGameServerHost;
|
||||||
struct LocationConfig;
|
struct LocationConfig;
|
||||||
|
|
||||||
// In-process implementation used by single-player. GameState exists exactly
|
// In-process implementation used by single-player. GameState exists exactly
|
||||||
@@ -30,6 +31,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
friend class LocalGameServerHost;
|
friend class LocalGameServerHost;
|
||||||
|
friend class RemoteGameServerHost;
|
||||||
|
|
||||||
// Host-only lifecycle operations. They never expose authoritative state and
|
// Host-only lifecycle operations. They never expose authoritative state and
|
||||||
// are intentionally absent from IGameServer.
|
// are intentionally absent from IGameServer.
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#include "NetworkProtocol.h"
|
||||||
|
#include <exception>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
|
||||||
|
std::string makeStateMessage(const nlohmann::json& state)
|
||||||
|
{
|
||||||
|
return nlohmann::json{
|
||||||
|
{"protocolVersion", kGameProtocolVersion},
|
||||||
|
{"type", "state"},
|
||||||
|
{"payload", state}
|
||||||
|
}.dump();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseStateMessage(
|
||||||
|
const std::string& message, nlohmann::json& state,
|
||||||
|
std::string& error)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
const nlohmann::json envelope = nlohmann::json::parse(message);
|
||||||
|
if (!envelope.is_object()) {
|
||||||
|
error = "message must be a JSON object";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (envelope.value("protocolVersion", -1) != kGameProtocolVersion) {
|
||||||
|
error = "unsupported protocol version";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (envelope.value("type", std::string()) != "state") {
|
||||||
|
error = "unsupported message type";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!envelope.contains("payload") || !envelope["payload"].is_object()) {
|
||||||
|
error = "state payload must be an object";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state = envelope["payload"];
|
||||||
|
error.clear();
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& exception) {
|
||||||
|
error = exception.what();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../external/nlohmann/json.hpp"
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
|
||||||
|
inline constexpr int kGameProtocolVersion = 1;
|
||||||
|
|
||||||
|
std::string makeStateMessage(const nlohmann::json& state);
|
||||||
|
bool parseStateMessage(
|
||||||
|
const std::string& message, nlohmann::json& state,
|
||||||
|
std::string& error);
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#include "RemoteGameServerHost.h"
|
||||||
|
#include "WebsocketGameClient.h"
|
||||||
|
#include "../Campaign.h"
|
||||||
|
#include "../Location.h"
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
|
||||||
|
RemoteGameServerHost::RemoteGameServerHost(WebsocketGameClient& client)
|
||||||
|
: client_(client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::initializeCampaign(
|
||||||
|
const std::string& campaignId, const std::string&,
|
||||||
|
const std::string& zipFile)
|
||||||
|
{
|
||||||
|
const CampaignConfig* campaign =
|
||||||
|
CampaignRegistry::instance().find(campaignId);
|
||||||
|
if (!campaign)
|
||||||
|
throw std::runtime_error("Unknown remote campaign '" + campaignId + "'");
|
||||||
|
client_.replica_.startCampaign(*campaign, zipFile, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::clearCampaign()
|
||||||
|
{
|
||||||
|
runtimeLocations_.clear();
|
||||||
|
client_.replica_.clearCampaign();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::resetCampaign(
|
||||||
|
const std::string&, const std::string&)
|
||||||
|
{
|
||||||
|
// The authoritative remote campaign cannot be reset by this client.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RemoteGameServerHost::prepareLocation(const LocationConfig& config)
|
||||||
|
{
|
||||||
|
return client_.replica_.prepareLocation(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::setupRuntimeLocation(
|
||||||
|
const std::string& id, const std::shared_ptr<Location>& location,
|
||||||
|
LocationSetup params, AudioPlayerAsync* audioPlayer)
|
||||||
|
{
|
||||||
|
auto stored = client_.replica_.state_.locations.find(id);
|
||||||
|
if (stored == client_.replica_.state_.locations.end())
|
||||||
|
throw std::runtime_error("Remote location state was not prepared");
|
||||||
|
params.definition = stored->second.definition;
|
||||||
|
params.gameState = &stored->second;
|
||||||
|
location->setup(params, audioPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Location* RemoteGameServerHost::runtimeLocation(const std::string& id) const
|
||||||
|
{
|
||||||
|
auto found = runtimeLocations_.find(id);
|
||||||
|
return found == runtimeLocations_.end() ? nullptr : found->second.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RemoteGameServerHost::hasRuntimeLocation(const std::string& id) const
|
||||||
|
{
|
||||||
|
return runtimeLocations_.count(id) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> RemoteGameServerHost::loadedRuntimeLocationIds() const
|
||||||
|
{
|
||||||
|
std::vector<std::string> result;
|
||||||
|
result.reserve(runtimeLocations_.size());
|
||||||
|
for (const auto& [id, location] : runtimeLocations_) result.push_back(id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::clearRuntimeLocations(bool)
|
||||||
|
{
|
||||||
|
runtimeLocations_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::bindRuntimeLocation(
|
||||||
|
const std::string& id, std::shared_ptr<Location> location)
|
||||||
|
{
|
||||||
|
runtimeLocations_[id] = std::move(location);
|
||||||
|
auto stored = client_.replica_.state_.locations.find(id);
|
||||||
|
if (stored != client_.replica_.state_.locations.end() &&
|
||||||
|
stored->second.hasPersistentState) {
|
||||||
|
runtimeLocations_[id]->applyAuxiliaryState(stored->second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::update(std::int64_t deltaMs)
|
||||||
|
{
|
||||||
|
client_.update(deltaMs);
|
||||||
|
const auto state = client_.snapshot();
|
||||||
|
if (!state || state->currentLocationId.empty()) return;
|
||||||
|
auto runtime = runtimeLocations_.find(state->currentLocationId);
|
||||||
|
if (runtime == runtimeLocations_.end() || !runtime->second) return;
|
||||||
|
runtime->second->update(deltaMs, LocationSimulationEvents{});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::save(nlohmann::json&)
|
||||||
|
{
|
||||||
|
// Persistence remains server-side.
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoteGameServerHost::load(const nlohmann::json&)
|
||||||
|
{
|
||||||
|
// Persistence remains server-side.
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "IGameServerHost.h"
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
|
||||||
|
class WebsocketGameClient;
|
||||||
|
|
||||||
|
class RemoteGameServerHost final : public IGameServerHost {
|
||||||
|
public:
|
||||||
|
explicit RemoteGameServerHost(WebsocketGameClient& client);
|
||||||
|
|
||||||
|
void initializeCampaign(
|
||||||
|
const std::string& campaignId, const std::string& questsPath,
|
||||||
|
const std::string& zipFile) override;
|
||||||
|
void clearCampaign() override;
|
||||||
|
void resetCampaign(
|
||||||
|
const std::string& questsPath, const std::string& zipFile) override;
|
||||||
|
bool prepareLocation(const LocationConfig& config) override;
|
||||||
|
void setupRuntimeLocation(
|
||||||
|
const std::string& id, const std::shared_ptr<Location>& location,
|
||||||
|
LocationSetup params, AudioPlayerAsync* audioPlayer) override;
|
||||||
|
Location* runtimeLocation(const std::string& id) const override;
|
||||||
|
bool hasRuntimeLocation(const std::string& id) const override;
|
||||||
|
std::vector<std::string> loadedRuntimeLocationIds() const override;
|
||||||
|
void clearRuntimeLocations(bool retainState) override;
|
||||||
|
void bindRuntimeLocation(
|
||||||
|
const std::string& id, std::shared_ptr<Location> location) override;
|
||||||
|
void update(std::int64_t deltaMs) override;
|
||||||
|
void save(nlohmann::json& out) override;
|
||||||
|
void load(const nlohmann::json& in) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
WebsocketGameClient& client_;
|
||||||
|
std::unordered_map<std::string, std::shared_ptr<Location>> runtimeLocations_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#include "WebsocketGameClient.h"
|
||||||
|
#include "NetworkProtocol.h"
|
||||||
|
#include <boost/asio.hpp>
|
||||||
|
#include <boost/beast.hpp>
|
||||||
|
#include <atomic>
|
||||||
|
#include <iostream>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
namespace asio = boost::asio;
|
||||||
|
namespace beast = boost::beast;
|
||||||
|
namespace websocket = beast::websocket;
|
||||||
|
using tcp = asio::ip::tcp;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class WebsocketGameClient::Impl {
|
||||||
|
public:
|
||||||
|
Impl(std::string host, std::string port, std::string path)
|
||||||
|
: resolver_(io_)
|
||||||
|
, socket_(io_)
|
||||||
|
, host_(std::move(host))
|
||||||
|
, port_(std::move(port))
|
||||||
|
, path_(std::move(path))
|
||||||
|
{
|
||||||
|
resolve();
|
||||||
|
thread_ = std::thread([this]() { io_.run(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
~Impl()
|
||||||
|
{
|
||||||
|
io_.stop();
|
||||||
|
if (thread_.joinable()) thread_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> takeMessages()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(messagesMutex_);
|
||||||
|
std::vector<std::string> result;
|
||||||
|
result.swap(messages_);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool connected() const { return connected_.load(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void resolve()
|
||||||
|
{
|
||||||
|
resolver_.async_resolve(
|
||||||
|
host_, port_,
|
||||||
|
[this](beast::error_code error, tcp::resolver::results_type results) {
|
||||||
|
if (error) return fail("resolve", error);
|
||||||
|
asio::async_connect(
|
||||||
|
socket_.next_layer(), results,
|
||||||
|
[this](beast::error_code connectError,
|
||||||
|
const tcp::endpoint&) {
|
||||||
|
if (connectError) return fail("connect", connectError);
|
||||||
|
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_ << '\n';
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void fail(const char* operation, beast::error_code error)
|
||||||
|
{
|
||||||
|
connected_.store(false);
|
||||||
|
std::cerr << "[network] WebSocket " << operation << " failed: "
|
||||||
|
<< error.message() << '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
asio::io_context io_;
|
||||||
|
tcp::resolver resolver_;
|
||||||
|
websocket::stream<tcp::socket> socket_;
|
||||||
|
beast::flat_buffer buffer_;
|
||||||
|
std::thread thread_;
|
||||||
|
std::string host_;
|
||||||
|
std::string port_;
|
||||||
|
std::string path_;
|
||||||
|
std::atomic_bool connected_{false};
|
||||||
|
std::mutex messagesMutex_;
|
||||||
|
std::vector<std::string> messages_;
|
||||||
|
};
|
||||||
|
|
||||||
|
WebsocketGameClient::WebsocketGameClient(
|
||||||
|
std::string host, std::string port, std::string path)
|
||||||
|
: impl_(std::make_unique<Impl>(
|
||||||
|
std::move(host), std::move(port), std::move(path)))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
WebsocketGameClient::~WebsocketGameClient() = default;
|
||||||
|
|
||||||
|
std::shared_ptr<const GameSnapshot> WebsocketGameClient::snapshot() const
|
||||||
|
{
|
||||||
|
return replica_.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& WebsocketGameClient::localPlayerId() const
|
||||||
|
{
|
||||||
|
return replica_.localPlayerId();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebsocketGameClient::submit(const GameCommand&)
|
||||||
|
{
|
||||||
|
// Commands intentionally remain local no-ops in the first iteration.
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<GameServerEvent> WebsocketGameClient::drainEvents()
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebsocketGameClient::update(std::int64_t)
|
||||||
|
{
|
||||||
|
std::vector<std::string> messages = impl_->takeMessages();
|
||||||
|
if (messages.empty()) return;
|
||||||
|
|
||||||
|
// State messages are complete snapshots; only the newest queued one matters.
|
||||||
|
for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
|
||||||
|
nlohmann::json state;
|
||||||
|
std::string error;
|
||||||
|
if (!parseStateMessage(*it, state, error)) {
|
||||||
|
std::cerr << "[network] Ignoring WebSocket message: "
|
||||||
|
<< error << '\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
replica_.load(state);
|
||||||
|
} catch (const std::exception& exception) {
|
||||||
|
std::cerr << "[network] Ignoring invalid game state: "
|
||||||
|
<< exception.what() << '\n';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebsocketGameClient::save(nlohmann::json&) const
|
||||||
|
{
|
||||||
|
// Persistence remains server-side.
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebsocketGameClient::load(const nlohmann::json&)
|
||||||
|
{
|
||||||
|
// Persistence remains server-side.
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WebsocketGameClient::connected() const
|
||||||
|
{
|
||||||
|
return impl_->connected();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "IGameServer.h"
|
||||||
|
#include "InternalGameServer.h"
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
|
||||||
|
class RemoteGameServerHost;
|
||||||
|
|
||||||
|
class WebsocketGameClient final : public IGameServer {
|
||||||
|
public:
|
||||||
|
WebsocketGameClient(
|
||||||
|
std::string host = "127.0.0.1",
|
||||||
|
std::string port = "8080",
|
||||||
|
std::string path = "/");
|
||||||
|
~WebsocketGameClient();
|
||||||
|
|
||||||
|
WebsocketGameClient(const WebsocketGameClient&) = delete;
|
||||||
|
WebsocketGameClient& operator=(const WebsocketGameClient&) = delete;
|
||||||
|
|
||||||
|
std::shared_ptr<const GameSnapshot> snapshot() const override;
|
||||||
|
const std::string& localPlayerId() const override;
|
||||||
|
void submit(const GameCommand& command) override;
|
||||||
|
std::vector<GameServerEvent> drainEvents() override;
|
||||||
|
void update(std::int64_t deltaMs) override;
|
||||||
|
void save(nlohmann::json& out) const override;
|
||||||
|
void load(const nlohmann::json& in) override;
|
||||||
|
|
||||||
|
bool connected() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend class RemoteGameServerHost;
|
||||||
|
class Impl;
|
||||||
|
InternalGameServer replica_;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
#include "WebsocketGameServer.h"
|
||||||
|
#include "InternalGameServer.h"
|
||||||
|
#include "NetworkProtocol.h"
|
||||||
|
#include <boost/asio.hpp>
|
||||||
|
#include <boost/beast.hpp>
|
||||||
|
#include <deque>
|
||||||
|
#include <iostream>
|
||||||
|
#include <memory>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
namespace asio = boost::asio;
|
||||||
|
namespace beast = boost::beast;
|
||||||
|
namespace websocket = beast::websocket;
|
||||||
|
using tcp = asio::ip::tcp;
|
||||||
|
|
||||||
|
class ServerSession : public std::enable_shared_from_this<ServerSession> {
|
||||||
|
public:
|
||||||
|
explicit ServerSession(tcp::socket socket)
|
||||||
|
: socket_(std::move(socket))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void start(std::string initialState)
|
||||||
|
{
|
||||||
|
socket_.set_option(
|
||||||
|
websocket::stream_base::timeout::suggested(beast::role_type::server));
|
||||||
|
socket_.async_accept(
|
||||||
|
[self = shared_from_this(), initialState = std::move(initialState)](
|
||||||
|
beast::error_code error) mutable {
|
||||||
|
if (error) {
|
||||||
|
std::cerr << "[network] WebSocket accept failed: "
|
||||||
|
<< error.message() << '\n';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->socket_.text(true);
|
||||||
|
self->send(std::move(initialState));
|
||||||
|
self->read();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void send(std::string message)
|
||||||
|
{
|
||||||
|
const bool writeInProgress = !outbound_.empty();
|
||||||
|
outbound_.push_back(std::move(message));
|
||||||
|
if (!writeInProgress) write();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void read()
|
||||||
|
{
|
||||||
|
socket_.async_read(
|
||||||
|
inbound_,
|
||||||
|
[self = shared_from_this()](beast::error_code error, std::size_t) {
|
||||||
|
if (error) {
|
||||||
|
if (error != websocket::error::closed)
|
||||||
|
std::cerr << "[network] WebSocket read failed: "
|
||||||
|
<< error.message() << '\n';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Commands are intentionally ignored in the first iteration.
|
||||||
|
self->inbound_.consume(self->inbound_.size());
|
||||||
|
self->read();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void write()
|
||||||
|
{
|
||||||
|
socket_.async_write(
|
||||||
|
asio::buffer(outbound_.front()),
|
||||||
|
[self = shared_from_this()](beast::error_code error, std::size_t) {
|
||||||
|
if (error) {
|
||||||
|
std::cerr << "[network] WebSocket write failed: "
|
||||||
|
<< error.message() << '\n';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->outbound_.pop_front();
|
||||||
|
if (!self->outbound_.empty()) self->write();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
websocket::stream<tcp::socket> socket_;
|
||||||
|
beast::flat_buffer inbound_;
|
||||||
|
std::deque<std::string> outbound_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class WebsocketGameServer::Impl {
|
||||||
|
public:
|
||||||
|
Impl(const std::string& address, std::uint16_t port, std::string initialState)
|
||||||
|
: acceptor_(io_)
|
||||||
|
, latestState_(std::move(initialState))
|
||||||
|
{
|
||||||
|
const tcp::endpoint endpoint(asio::ip::make_address(address), port);
|
||||||
|
acceptor_.open(endpoint.protocol());
|
||||||
|
acceptor_.set_option(asio::socket_base::reuse_address(true));
|
||||||
|
acceptor_.bind(endpoint);
|
||||||
|
acceptor_.listen(asio::socket_base::max_listen_connections);
|
||||||
|
port_ = acceptor_.local_endpoint().port();
|
||||||
|
accept();
|
||||||
|
thread_ = std::thread([this]() { io_.run(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
~Impl()
|
||||||
|
{
|
||||||
|
asio::post(io_, [this]() {
|
||||||
|
beast::error_code ignored;
|
||||||
|
acceptor_.close(ignored);
|
||||||
|
});
|
||||||
|
io_.stop();
|
||||||
|
if (thread_.joinable()) thread_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
void broadcast(std::string state)
|
||||||
|
{
|
||||||
|
asio::post(io_, [this, state = std::move(state)]() mutable {
|
||||||
|
latestState_ = std::move(state);
|
||||||
|
auto output = sessions_.begin();
|
||||||
|
for (auto input = sessions_.begin(); input != sessions_.end(); ++input) {
|
||||||
|
if (auto session = input->lock()) {
|
||||||
|
session->send(latestState_);
|
||||||
|
*output++ = *input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions_.erase(output, sessions_.end());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint16_t port() const { return port_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void accept()
|
||||||
|
{
|
||||||
|
acceptor_.async_accept(
|
||||||
|
[this](beast::error_code error, tcp::socket socket) {
|
||||||
|
if (!error) {
|
||||||
|
auto session =
|
||||||
|
std::make_shared<ServerSession>(std::move(socket));
|
||||||
|
sessions_.push_back(session);
|
||||||
|
session->start(latestState_);
|
||||||
|
} else if (error != asio::error::operation_aborted) {
|
||||||
|
std::cerr << "[network] TCP accept failed: "
|
||||||
|
<< error.message() << '\n';
|
||||||
|
}
|
||||||
|
if (acceptor_.is_open()) accept();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
asio::io_context io_;
|
||||||
|
tcp::acceptor acceptor_;
|
||||||
|
std::thread thread_;
|
||||||
|
std::vector<std::weak_ptr<ServerSession>> sessions_;
|
||||||
|
std::string latestState_;
|
||||||
|
std::uint16_t port_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
WebsocketGameServer::WebsocketGameServer(
|
||||||
|
InternalGameServer& gameServer,
|
||||||
|
const std::string& address,
|
||||||
|
std::uint16_t port)
|
||||||
|
: gameServer_(gameServer)
|
||||||
|
{
|
||||||
|
nlohmann::json state;
|
||||||
|
gameServer_.save(state);
|
||||||
|
impl_ = std::make_unique<Impl>(
|
||||||
|
address, port, makeStateMessage(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
WebsocketGameServer::~WebsocketGameServer() = default;
|
||||||
|
|
||||||
|
void WebsocketGameServer::update(std::int64_t deltaMs)
|
||||||
|
{
|
||||||
|
gameServer_.update(deltaMs);
|
||||||
|
broadcastElapsedMs_ += deltaMs;
|
||||||
|
if (broadcastElapsedMs_ >= 5000) {
|
||||||
|
broadcastElapsedMs_ %= 5000;
|
||||||
|
broadcastState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebsocketGameServer::broadcastState()
|
||||||
|
{
|
||||||
|
nlohmann::json state;
|
||||||
|
gameServer_.save(state);
|
||||||
|
impl_->broadcast(makeStateMessage(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint16_t WebsocketGameServer::port() const
|
||||||
|
{
|
||||||
|
return impl_->port();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace FRG {
|
||||||
|
|
||||||
|
class InternalGameServer;
|
||||||
|
|
||||||
|
class WebsocketGameServer {
|
||||||
|
public:
|
||||||
|
WebsocketGameServer(
|
||||||
|
InternalGameServer& gameServer,
|
||||||
|
const std::string& address = "0.0.0.0",
|
||||||
|
std::uint16_t port = 8080);
|
||||||
|
~WebsocketGameServer();
|
||||||
|
|
||||||
|
WebsocketGameServer(const WebsocketGameServer&) = delete;
|
||||||
|
WebsocketGameServer& operator=(const WebsocketGameServer&) = delete;
|
||||||
|
|
||||||
|
void update(std::int64_t deltaMs);
|
||||||
|
void broadcastState();
|
||||||
|
std::uint16_t port() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
class Impl;
|
||||||
|
InternalGameServer& gameServer_;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
std::int64_t broadcastElapsedMs_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace FRG
|
||||||
Reference in new issue
Block a user