From 075750442f0215075f3f4fbf891f56353b078228 Mon Sep 17 00:00:00 2001 From: Vladislav Khorev Date: Fri, 30 Oct 2026 16:37:30 +0600 Subject: [PATCH] Implemented the real server --- WEBSOCKET_NETWORKING.md | 143 ++++++++++++++++ proj-web/CMakeLists.txt | 1 - proj-windows/CMakeLists.txt | 15 +- server/CMakeLists.txt | 33 +++- server/README.md | 23 ++- server/src/main.cpp | 44 ++++- server/tests/LocationGameStateTests.cpp | 8 + server/tests/NetworkProtocolTests.cpp | 47 ++++++ src/Game.cpp | 72 ++++++--- src/Game.h | 21 ++- src/Location.h | 1 + src/game_server/GameServerHostRouter.h | 60 +++++++ src/game_server/GameServerRouter.h | 36 +++++ src/game_server/InternalGameServer.h | 2 + src/game_server/NetworkProtocol.cpp | 46 ++++++ src/game_server/NetworkProtocol.h | 15 ++ src/game_server/RemoteGameServerHost.cpp | 109 +++++++++++++ src/game_server/RemoteGameServerHost.h | 39 +++++ src/game_server/WebsocketGameClient.cpp | 189 ++++++++++++++++++++++ src/game_server/WebsocketGameClient.h | 40 +++++ src/game_server/WebsocketGameServer.cpp | 197 +++++++++++++++++++++++ src/game_server/WebsocketGameServer.h | 33 ++++ 22 files changed, 1130 insertions(+), 44 deletions(-) create mode 100644 WEBSOCKET_NETWORKING.md create mode 100644 server/tests/NetworkProtocolTests.cpp create mode 100644 src/game_server/GameServerHostRouter.h create mode 100644 src/game_server/GameServerRouter.h create mode 100644 src/game_server/NetworkProtocol.cpp create mode 100644 src/game_server/NetworkProtocol.h create mode 100644 src/game_server/RemoteGameServerHost.cpp create mode 100644 src/game_server/RemoteGameServerHost.h create mode 100644 src/game_server/WebsocketGameClient.cpp create mode 100644 src/game_server/WebsocketGameClient.h create mode 100644 src/game_server/WebsocketGameServer.cpp create mode 100644 src/game_server/WebsocketGameServer.h diff --git a/WEBSOCKET_NETWORKING.md b/WEBSOCKET_NETWORKING.md new file mode 100644 index 0000000..06df3ee --- /dev/null +++ b/WEBSOCKET_NETWORKING.md @@ -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. diff --git a/proj-web/CMakeLists.txt b/proj-web/CMakeLists.txt index c2c7116..5603e9d 100644 --- a/proj-web/CMakeLists.txt +++ b/proj-web/CMakeLists.txt @@ -202,7 +202,6 @@ set(EMSCRIPTEN_FLAGS #"-pthread" #"-sUSE_PTHREADS=1" "-fexceptions" - "-DNETWORK" ) target_compile_options(ShadowOverBishkekDemo PRIVATE ${EMSCRIPTEN_FLAGS} "-O2") diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index 78b33ed..89f9fc5 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -78,6 +78,8 @@ add_executable(ShadowOverBishkekDemo WIN32 ../src/game_server/IGameServer.h ../src/game_server/IGameServerLifecycle.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.cpp ../src/game_server/LocalGameServerHost.h @@ -138,7 +140,9 @@ set_target_properties(ShadowOverBishkekDemo PROPERTIES # Определения препроцессора target_compile_definitions(ShadowOverBishkekDemo PRIVATE WIN32_LEAN_AND_MEAN + _WIN32_WINNT=0x0A00 PNG_ENABLED + NETWORK ) # Линкуем с SDL2main, если он вообще установлен @@ -160,7 +164,16 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE # Линкуем OpenGL (Windows) 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() # =========================================== diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index cb6ad49..94bceb5 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -105,6 +105,23 @@ 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) +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) if(BUILD_TESTING) @@ -116,14 +133,26 @@ if(BUILD_TESTING) add_test( NAME GameServer.LocationState 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( NAME GameServer.IdleSmoke COMMAND GameServer --ticks 2 --no-sleep) add_test( - NAME GameServer.BattleZoneZeroPlayers + NAME GameServer.BattleZoneSharedPlayer COMMAND GameServer --campaign battle_zone --location uni_exterior --ticks 2 --no-sleep) set_tests_properties( - GameServer.BattleZoneZeroPlayers + GameServer.BattleZoneSharedPlayer PROPERTIES WORKING_DIRECTORY "${GAME_ROOT}") endif() diff --git a/server/README.md b/server/README.md index a90fe74..84c36a4 100644 --- a/server/README.md +++ b/server/README.md @@ -1,8 +1,8 @@ # Headless GameServer This target runs the authoritative `InternalGameServer` without SDL, OpenGL, -audio, Lua, or rendering dependencies. It can prepare campaign/location data -headlessly; networking is not implemented yet. +audio, Lua, or rendering dependencies. On Windows it exposes the state through +a Boost.Beast WebSocket listener. ## 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 real-time delay. -Load a location with no players: +Start the first multiplayer transport iteration: -```bash -./server/build/GameServer --campaign battle_zone --location uni_exterior +```powershell +.\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. `--campaigns PATH` selects a different campaign registry. Content paths are resolved from the process working directory, so run from the repository root @@ -56,6 +62,8 @@ Available startup options: --campaign ID --location ID --campaigns PATH +--address IP +--port N --tick-ms N --ticks N --no-sleep @@ -64,8 +72,9 @@ Available startup options: The graphical client executes Lua/dialogue behavior after building its runtime `Location`; the headless content pass loads authoritative navigation, NPC, interactive-object, trigger, teleport, taxi, and animation metadata only. -The standalone lifecycle intentionally creates neither the local `"player"` nor -the battle-zone test bot. Those are opt-ins used by the graphical host. +The standalone WebSocket lifecycle creates the existing shared `"player"` so +the graphical client can render its local character. It does not create the +battle-zone test bot. ## Tests diff --git a/server/src/main.cpp b/server/src/main.cpp index d7278ad..3dd3903 100644 --- a/server/src/main.cpp +++ b/server/src/main.cpp @@ -1,4 +1,7 @@ #include "game_server/InternalGameServer.h" +#if defined(NETWORK) +#include "game_server/WebsocketGameServer.h" +#endif #include "Campaign.h" #include #include @@ -7,6 +10,7 @@ #include #include #include +#include #include #include @@ -38,12 +42,14 @@ std::int64_t parsePositiveNumber(const char* value, const char* option) int main(int argc, char** argv) { - std::int64_t tickMs = 50; + std::int64_t tickMs = 500; std::int64_t maxTicks = (std::numeric_limits::max)(); bool sleepBetweenTicks = true; std::string campaignId; std::string locationId; std::string campaignsPath = "resources/config/campaigns.json"; + std::string listenAddress = "0.0.0.0"; + std::int64_t listenPort = 8080; try { for (int index = 1; index < argc; ++index) { @@ -60,11 +66,17 @@ int main(int argc, char** argv) locationId = argv[++index]; } else if (option == "--campaigns" && index + 1 < argc) { 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") { std::cout << "Usage: GameServer [--ticks N] [--tick-ms N] " "[--no-sleep] [--campaign ID] [--location ID] " - "[--campaigns PATH]\n"; + "[--campaigns PATH] [--address IP] [--port N]\n"; return EXIT_SUCCESS; } else { throw std::runtime_error("Unknown or incomplete option: " + option); @@ -75,6 +87,10 @@ int main(int argc, char** argv) return EXIT_FAILURE; } + //Temporary override: + campaignId = "battle_zone"; + locationId = "uni_exterior"; + std::signal(SIGINT, requestStop); std::signal(SIGTERM, requestStop); @@ -95,12 +111,12 @@ int main(int argc, char** argv) throw std::runtime_error( "Campaign '" + campaignId + "' has no location '" + selectedLocation + "'"); - server.startCampaign(*campaign, std::string(), false, false); + server.startCampaign(*campaign, std::string(), true, false); server.prepareLocation(*location); server.activateLocation(selectedLocation); std::cout << "Loaded campaign '" << campaignId << "', location '" << selectedLocation - << "' with zero players\n"; + << "' with shared player '" << server.localPlayerId() << "'\n"; } catch (const std::exception& error) { std::cerr << "Server content setup failed: " << error.what() << '\n'; @@ -110,12 +126,30 @@ int main(int argc, char** argv) const auto tickDuration = std::chrono::milliseconds(tickMs); auto nextTick = std::chrono::steady_clock::now(); - std::cout << "GameServer started (idle headless mode, " +#if defined(NETWORK) + std::unique_ptr websocketServer; + try { + websocketServer = std::make_unique( + server, listenAddress, static_cast(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"; +#endif for (std::int64_t tick = 0; tick < maxTicks && !stopRequested.load(); ++tick) { nextTick += tickDuration; +#if defined(NETWORK) + websocketServer->update(tickMs); +#else server.update(tickMs); +#endif if (sleepBetweenTicks) std::this_thread::sleep_until(nextTick); } std::cout << "GameServer stopped\n"; diff --git a/server/tests/LocationGameStateTests.cpp b/server/tests/LocationGameStateTests.cpp index 63d409f..ca3a670 100644 --- a/server/tests/LocationGameStateTests.cpp +++ b/server/tests/LocationGameStateTests.cpp @@ -86,5 +86,13 @@ int main() 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; } diff --git a/server/tests/NetworkProtocolTests.cpp b/server/tests/NetworkProtocolTests.cpp new file mode 100644 index 0000000..3a0632c --- /dev/null +++ b/server/tests/NetworkProtocolTests.cpp @@ -0,0 +1,47 @@ +#include "game_server/NetworkProtocol.h" +#include +#include +#include + +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; +} diff --git a/src/Game.cpp b/src/Game.cpp index 345baf4..0985ad6 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -85,8 +85,10 @@ namespace FRG Game::Game() : audioPlayer(std::make_unique()) , internalGameServer() - , gameServerHost(internalGameServer) - , menuManager(renderer, internalGameServer, *audioPlayer) + , localGameServerHost(internalGameServer) + , gameServer(internalGameServer) + , gameServerHost(localGameServerHost) + , menuManager(renderer, gameServer, *audioPlayer) , newTickCount(0) , lastTickCount(0) { @@ -400,7 +402,7 @@ namespace FRG performResetToInitialState(); applyTutorialSetting(); const CampaignConfig& campaign = activeCampaign(); - internalGameServer.submit( + server().submit( SetCurrentLocationCommand{campaign.startLocation}); menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback(); @@ -549,12 +551,27 @@ namespace FRG const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId); if (!campaign) return; +#if defined(NETWORK) + remoteCampaignActive = campaignId == "battle_zone"; + if (remoteCampaignActive) { + websocketGameClient = std::make_unique(); + remoteGameServerHost = + std::make_unique(*websocketGameClient); + gameServer.use(*websocketGameClient); + gameServerHost.use(*remoteGameServerHost); + } else { + gameServer.use(internalGameServer); + gameServerHost.use(localGameServerHost); + } +#else + remoteCampaignActive = false; +#endif gameServerHost.initializeCampaign( campaignId, campaign->questsPath, CONST_ZIP_FILE); // The player stays loaded for the whole campaign; locations come and go around him. player = Location::createPlayer( - renderer, audioPlayer.get(), internalGameServer, - internalGameServer.localPlayerId()); + renderer, audioPlayer.get(), server(), + server().localPlayerId()); if (player) player->onDeathAnimComplete = [this]() { startDarklandsTransition(); }; } @@ -564,6 +581,13 @@ namespace FRG player.reset(); loadedCampaignId.clear(); 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 } @@ -609,7 +633,7 @@ namespace FRG const auto state = stateSnapshot(); if (!state) return; enterLocationThen(state->campaignId, destName, [this, destName, destPos, destRotY]() { - internalGameServer.submit(SetCurrentLocationCommand{destName}); + server().submit(SetCurrentLocationCommand{destName}); Location* loc = currentLocation(); if (!loc) return; if (loc->player) { @@ -624,13 +648,13 @@ namespace FRG const float inclination = locationState ? locationState->cameraInclination : 1.1036f; - internalGameServer.submit(SetLocationCameraCommand{ + server().submit(SetLocationCameraCommand{ destName, destRotY, inclination}); loc->scriptEngine.callLocationEnterCallback(); //Reset taxi - internalGameServer.submit(ResetTaxiCommand{destName}); - internalGameServer.submit(SetTaxiCalledCommand{false}); + server().submit(ResetTaxiCommand{destName}); + server().submit(SetTaxiCalledCommand{false}); menuManager.onLocationChanged(destName); }); @@ -693,7 +717,7 @@ namespace FRG // (if the campaign has one); a disabled tutorial is simply one that is already completed. void Game::applyTutorialSetting() { if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting) - internalGameServer.submit( + server().submit( SetTutorialStepCommand{TutorialStep::Completed}); } @@ -731,20 +755,20 @@ namespace FRG params.player = player; auto loc = std::make_shared( - renderer, internalGameServer, config.id); + renderer, server(), config.id); gameServerHost.setupRuntimeLocation( config.id, loc, std::move(params), audioPlayer.get()); loc->requestNightDayTransition = [this](bool isNight, bool isDawn) { const auto state = stateSnapshot(); if (!state) return; - internalGameServer.submit(SetWorldModeCommand{ + server().submit(SetWorldModeCommand{ state->isDarklands, isNight, isDawn}); }; loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); }; loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); }; loc->requestReturnToMainMenu = [this]() { - internalGameServer.submit(SetCurrentLocationCommand{std::string()}); + server().submit(SetCurrentLocationCommand{std::string()}); menuManager.showMenuAfterGameCompleted(); }; 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. { loc->dialogueSystem.setOnDialogueAdvanced([this]() { - internalGameServer.submit( + server().submit( CompleteTutorialStepCommand{TutorialStep::AdvanceDialogue}); }); loc->onPlayerFloorWalk = [this]() { - internalGameServer.submit( + server().submit( CompleteTutorialStepCommand{TutorialStep::Walk}); menuManager.onPlayerStartedWalking(); }; @@ -840,9 +864,9 @@ namespace FRG loc->player->isMoving() == false && loc->player->getHp() > 0) { - internalGameServer.submit(SetCharacterTargetCommand{ + server().submit(SetCharacterTargetCommand{ loc->locationId(), - internalGameServer.localPlayerId(), attackerId}); + server().localPlayerId(), attackerId}); } } @@ -1134,7 +1158,7 @@ namespace FRG if (!menuManager.isMainMenuOpen()) menuManager.update(static_cast(delta)); - if (loadingCompleted && currentLocation()) + if (loadingCompleted) { // The authoritative implementation advances simulation; a future // network client can use the same call to poll/apply server updates. @@ -1147,7 +1171,7 @@ namespace FRG void Game::processServerEvents() { - for (GameServerEvent& event : internalGameServer.drainEvents()) { + for (GameServerEvent& event : server().drainEvents()) { std::visit([this](const auto& value) { using T = std::decay_t; if constexpr (std::is_same_v) { @@ -1380,7 +1404,7 @@ namespace FRG } //logger() << "Current zoom: " << Environment::zoom << std::endl; // Tutorial: any mouse-wheel scroll counts as the "zoom gesture". - internalGameServer.submit( + server().submit( CompleteTutorialStepCommand{TutorialStep::Zoom}); } @@ -1564,7 +1588,7 @@ namespace FRG // Tutorial: detect a significant pinch-zoom (≥ 2 zoom units). if (std::abs(Environment::zoom - pinchStartZoom) >= 2.0f) { - internalGameServer.submit( + server().submit( CompleteTutorialStepCommand{TutorialStep::Zoom}); } } @@ -1717,7 +1741,7 @@ namespace FRG float deltaAz = std::abs(state->cameraAzimuth - dragStartAzimuth); float deltaInc = std::abs(state->cameraInclination - dragStartInclination); if (deltaAz >= TUTORIAL_ROTATION_THRESHOLD && deltaInc >= TUTORIAL_ROTATION_THRESHOLD) { - internalGameServer.submit( + server().submit( CompleteTutorialStepCommand{TutorialStep::RotateCamera}); } } @@ -1770,7 +1794,7 @@ namespace FRG const auto before = stateSnapshot(); if (!before) return; if (isNightTransition) { - internalGameServer.submit(SetWorldModeCommand{ + server().submit(SetWorldModeCommand{ before->isDarklands, !before->isNight, before->isDawn}); if (currentLocation()) { @@ -1781,7 +1805,7 @@ namespace FRG menuManager.lastMusicTrack = "music/bishkek night.ogg"; } else { - internalGameServer.submit(SetWorldModeCommand{ + server().submit(SetWorldModeCommand{ !before->isDarklands, before->isNight, before->isDawn}); const auto after = stateSnapshot(); if (!after) return; diff --git a/src/Game.h b/src/Game.h index aff7c86..4923186 100644 --- a/src/Game.h +++ b/src/Game.h @@ -23,6 +23,12 @@ #include "AudioPlayerAsync.h" #include "game_server/InternalGameServer.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 { struct LocationConfig; @@ -84,11 +90,18 @@ namespace FRG { private: InternalGameServer internalGameServer; - LocalGameServerHost gameServerHost; + LocalGameServerHost localGameServerHost; +#if defined(NETWORK) + std::unique_ptr websocketGameClient; + std::unique_ptr remoteGameServerHost; +#endif + GameServerRouter gameServer; + GameServerHostRouter gameServerHost; + bool remoteCampaignActive = false; public: - IGameServer& server() { return internalGameServer; } - const IGameServer& server() const { return internalGameServer; } + IGameServer& server() { return gameServer; } + const IGameServer& server() const { return gameServer; } MenuManager menuManager; @@ -131,7 +144,7 @@ namespace FRG { Location* currentLocation() const; std::shared_ptr stateSnapshot() const { - return internalGameServer.snapshot(); + return gameServer.snapshot(); } void saveGame(int slot); diff --git a/src/Location.h b/src/Location.h index 37e537d..dfae730 100644 --- a/src/Location.h +++ b/src/Location.h @@ -227,6 +227,7 @@ namespace FRG friend class Game; friend class ScriptEngine; friend class LocalGameServerHost; + friend class RemoteGameServerHost; Renderer& renderer; AudioPlayerAsync* audioPlayer; diff --git a/src/game_server/GameServerHostRouter.h b/src/game_server/GameServerHostRouter.h new file mode 100644 index 0000000..ca5550e --- /dev/null +++ b/src/game_server/GameServerHostRouter.h @@ -0,0 +1,60 @@ +#pragma once + +#include "IGameServerHost.h" +#include "../Location.h" +#include + +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, + 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 loadedRuntimeLocationIds() const override { + return active_->loadedRuntimeLocationIds(); + } + void clearRuntimeLocations(bool retainState) override { + active_->clearRuntimeLocations(retainState); + } + void bindRuntimeLocation( + const std::string& id, std::shared_ptr 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 diff --git a/src/game_server/GameServerRouter.h b/src/game_server/GameServerRouter.h new file mode 100644 index 0000000..69d180a --- /dev/null +++ b/src/game_server/GameServerRouter.h @@ -0,0 +1,36 @@ +#pragma once + +#include "IGameServer.h" +#include + +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 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 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 diff --git a/src/game_server/InternalGameServer.h b/src/game_server/InternalGameServer.h index ecd3c8e..3372eee 100644 --- a/src/game_server/InternalGameServer.h +++ b/src/game_server/InternalGameServer.h @@ -6,6 +6,7 @@ namespace FRG { class LocalGameServerHost; +class RemoteGameServerHost; struct LocationConfig; // In-process implementation used by single-player. GameState exists exactly @@ -30,6 +31,7 @@ public: private: friend class LocalGameServerHost; + friend class RemoteGameServerHost; // Host-only lifecycle operations. They never expose authoritative state and // are intentionally absent from IGameServer. diff --git a/src/game_server/NetworkProtocol.cpp b/src/game_server/NetworkProtocol.cpp new file mode 100644 index 0000000..1440dee --- /dev/null +++ b/src/game_server/NetworkProtocol.cpp @@ -0,0 +1,46 @@ +#include "NetworkProtocol.h" +#include + +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 diff --git a/src/game_server/NetworkProtocol.h b/src/game_server/NetworkProtocol.h new file mode 100644 index 0000000..d49764d --- /dev/null +++ b/src/game_server/NetworkProtocol.h @@ -0,0 +1,15 @@ +#pragma once + +#include "../external/nlohmann/json.hpp" +#include + +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 diff --git a/src/game_server/RemoteGameServerHost.cpp b/src/game_server/RemoteGameServerHost.cpp new file mode 100644 index 0000000..c8d43a4 --- /dev/null +++ b/src/game_server/RemoteGameServerHost.cpp @@ -0,0 +1,109 @@ +#include "RemoteGameServerHost.h" +#include "WebsocketGameClient.h" +#include "../Campaign.h" +#include "../Location.h" +#include + +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, + 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 RemoteGameServerHost::loadedRuntimeLocationIds() const +{ + std::vector 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) +{ + 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 diff --git a/src/game_server/RemoteGameServerHost.h b/src/game_server/RemoteGameServerHost.h new file mode 100644 index 0000000..5b97c0b --- /dev/null +++ b/src/game_server/RemoteGameServerHost.h @@ -0,0 +1,39 @@ +#pragma once + +#include "IGameServerHost.h" +#include + +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, + LocationSetup params, AudioPlayerAsync* audioPlayer) override; + Location* runtimeLocation(const std::string& id) const override; + bool hasRuntimeLocation(const std::string& id) const override; + std::vector loadedRuntimeLocationIds() const override; + void clearRuntimeLocations(bool retainState) override; + void bindRuntimeLocation( + const std::string& id, std::shared_ptr 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> runtimeLocations_; +}; + +} // namespace FRG diff --git a/src/game_server/WebsocketGameClient.cpp b/src/game_server/WebsocketGameClient.cpp new file mode 100644 index 0000000..863c2cd --- /dev/null +++ b/src/game_server/WebsocketGameClient.cpp @@ -0,0 +1,189 @@ +#include "WebsocketGameClient.h" +#include "NetworkProtocol.h" +#include +#include +#include +#include +#include +#include +#include +#include + +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 takeMessages() + { + std::lock_guard lock(messagesMutex_); + std::vector 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 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 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 messages_; +}; + +WebsocketGameClient::WebsocketGameClient( + std::string host, std::string port, std::string path) + : impl_(std::make_unique( + std::move(host), std::move(port), std::move(path))) +{ +} + +WebsocketGameClient::~WebsocketGameClient() = default; + +std::shared_ptr 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 WebsocketGameClient::drainEvents() +{ + return {}; +} + +void WebsocketGameClient::update(std::int64_t) +{ + std::vector 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 diff --git a/src/game_server/WebsocketGameClient.h b/src/game_server/WebsocketGameClient.h new file mode 100644 index 0000000..95bf9e0 --- /dev/null +++ b/src/game_server/WebsocketGameClient.h @@ -0,0 +1,40 @@ +#pragma once + +#include "IGameServer.h" +#include "InternalGameServer.h" +#include +#include + +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 snapshot() const override; + const std::string& localPlayerId() const override; + void submit(const GameCommand& command) override; + std::vector 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_; +}; + +} // namespace FRG diff --git a/src/game_server/WebsocketGameServer.cpp b/src/game_server/WebsocketGameServer.cpp new file mode 100644 index 0000000..45f0f9f --- /dev/null +++ b/src/game_server/WebsocketGameServer.cpp @@ -0,0 +1,197 @@ +#include "WebsocketGameServer.h" +#include "InternalGameServer.h" +#include "NetworkProtocol.h" +#include +#include +#include +#include +#include +#include +#include + +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 { +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 socket_; + beast::flat_buffer inbound_; + std::deque 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(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> 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( + 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 diff --git a/src/game_server/WebsocketGameServer.h b/src/game_server/WebsocketGameServer.h new file mode 100644 index 0000000..8bf4d83 --- /dev/null +++ b/src/game_server/WebsocketGameServer.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +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_; + std::int64_t broadcastElapsedMs_ = 0; +}; + +} // namespace FRG