Implemented the real server

This commit is contained in:
Vladislav Khorev committed 2026-10-30 16:37:30 +06:00
1 parent 931236bec0
commit 075750442f
22 files changed
+1130 -44

No files matched your search

+48 -24
View File
@@ -85,8 +85,10 @@ namespace FRG
Game::Game()
: audioPlayer(std::make_unique<AudioPlayerAsync>())
, 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<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(
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<Location>(
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<float>(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<decltype(value)>;
if constexpr (std::is_same_v<T, InventoryItemAddedEvent>) {
@@ -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;
+17 -4
View File
@@ -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> websocketGameClient;
std::unique_ptr<RemoteGameServerHost> 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<const GameSnapshot> stateSnapshot() const {
return internalGameServer.snapshot();
return gameServer.snapshot();
}
void saveGame(int slot);
+1
View File
@@ -227,6 +227,7 @@ namespace FRG
friend class Game;
friend class ScriptEngine;
friend class LocalGameServerHost;
friend class RemoteGameServerHost;
Renderer& renderer;
AudioPlayerAsync* audioPlayer;
+60
View File
@@ -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
+36
View File
@@ -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
+2
View File
@@ -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.
+46
View File
@@ -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
+15
View File
@@ -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
+109
View File
@@ -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
+39
View File
@@ -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
+189
View File
@@ -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
+40
View File
@@ -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
+197
View File
@@ -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
+33
View File
@@ -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