From 365f1243e5370d30fa7ddc2e4db57a31f43a89fe Mon Sep 17 00:00:00 2001 From: Oliver Benz Date: Sat, 31 Jan 2026 20:45:59 +0100 Subject: [PATCH 1/6] App,GameNet: Keep track of chat messages, User player>seat. --- src/App/gameServer.cpp | 3 ++- src/App/include/app/gameServer.hpp | 7 +++++++ src/GameNet/include/gameNet/nwEvents.hpp | 5 ++++- src/GameNet/nwEvents.cpp | 19 ++++++++++++------- tests/gameNet/mockClient.cpp | 8 ++++++-- tests/gameNet/mockClient.hpp | 3 --- tests/gameNet/mockServer.cpp | 4 +++- tests/gameNet/nwEvents.gtest.cpp | 9 +++++---- 8 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/App/gameServer.cpp b/src/App/gameServer.cpp index e1294dd..c8127c0 100644 --- a/src/App/gameServer.cpp +++ b/src/App/gameServer.cpp @@ -155,7 +155,8 @@ void GameServer::handleNetworkEvent(Player player, const gameNet::ClientResign&) } void GameServer::handleNetworkEvent(Player player, const gameNet::ClientChat& event) { - m_server.broadcast(gameNet::ServerChat{.seat = (player == Player::Black ? gameNet::Seat::Black : gameNet::Seat::White), .message = event.message}); + m_chatHistory.emplace_back(ChatEntry{player, event.message}); + m_server.broadcast(gameNet::ServerChat{player, m_chatHistory.size(), event.message}); } } // namespace go::app diff --git a/src/App/include/app/gameServer.hpp b/src/App/include/app/gameServer.hpp index 1782159..19d6c90 100644 --- a/src/App/include/app/gameServer.hpp +++ b/src/App/include/app/gameServer.hpp @@ -2,6 +2,7 @@ #include "core/IGameStateListener.hpp" #include "core/game.hpp" +#include "data/player.hpp" #include "gameNet/server.hpp" #include @@ -15,6 +16,11 @@ namespace go { namespace app { +struct ChatEntry { + Player player; + std::string message; +}; + class GameServer : public gameNet::IServerHandler, public IGameStateListener { public: explicit GameServer(std::size_t boardSize = 9u); @@ -43,6 +49,7 @@ class GameServer : public gameNet::IServerHandler, public IGameStateListener { std::thread m_gameThread; //!< Runs the game loop. std::unordered_map m_players; + std::vector m_chatHistory; gameNet::Server m_server{}; }; diff --git a/src/GameNet/include/gameNet/nwEvents.hpp b/src/GameNet/include/gameNet/nwEvents.hpp index 3766bb1..f44ec34 100644 --- a/src/GameNet/include/gameNet/nwEvents.hpp +++ b/src/GameNet/include/gameNet/nwEvents.hpp @@ -1,6 +1,7 @@ #pragma once #include "data/coordinate.hpp" +#include "data/player.hpp" #include "gameNet/types.hpp" #include @@ -32,6 +33,7 @@ struct ServerGameConfig { unsigned timeSeconds; }; +// TODO: Replace seat with player //! Board update event with relevant data so the client can apply the delta. struct ServerDelta { unsigned turn; //!< Move number of game. @@ -44,7 +46,8 @@ struct ServerDelta { }; struct ServerChat { - Seat seat; //!< Only player values. + Player player; //!< Player who sent the message. + unsigned messageId; //!< Unique identifier. std::string message; //!< Chat message. }; diff --git a/src/GameNet/nwEvents.cpp b/src/GameNet/nwEvents.cpp index 2767d9f..fdf989b 100644 --- a/src/GameNet/nwEvents.cpp +++ b/src/GameNet/nwEvents.cpp @@ -136,9 +136,10 @@ static std::string toMessage(const ServerDelta& e) { static std::string toMessage(const ServerChat& e) { json j; - j["type"] = "chat"; - j["seat"] = static_cast(e.seat); - j["message"] = e.message; + j["type"] = "chat"; + j["player"] = static_cast(e.player); + j["messageId"] = e.messageId; + j["message"] = e.message; return j.dump(); } std::string toMessage(ServerEvent event) { @@ -234,14 +235,18 @@ std::optional fromServerMessage(const std::string& message) { return fromServerDeltaMessage(j); } if (type == "chat") { - if (!j.contains("seat") || !j["seat"].is_number_unsigned() || !j.contains("message") || !j["message"].is_string()) { + if (!j.contains("player") || !j["player"].is_number_unsigned() || !j.contains("messageId") || !j["messageId"].is_number_unsigned() || + !j.contains("message") || !j["message"].is_string()) { return {}; } - const auto seat = static_cast(j["seat"].get()); - if (!isPlayer(seat)) { + if (j["player"].get() != static_cast(Player::Black) && j["player"].get() != static_cast(Player::White)) { return {}; } - return ServerChat{.seat = seat, .message = j["message"].get()}; + + const auto player = static_cast(j["player"].get()); + const auto chatMessageId = j["messageId"].get(); + const auto chatMessage = j["message"].get(); + return ServerChat{player, chatMessageId, std::move(chatMessage)}; } return {}; } diff --git a/tests/gameNet/mockClient.cpp b/tests/gameNet/mockClient.cpp index 2905483..69720c0 100644 --- a/tests/gameNet/mockClient.cpp +++ b/tests/gameNet/mockClient.cpp @@ -28,11 +28,15 @@ void MockClient::tryPlace(unsigned x, unsigned y) { m_network.send(gameNet::ClientPutStone{.c = {x, y}}); } -std::string MockClient::toString(gameNet::Seat seat) { +static std::string toString(const Player player) { + return player == Player::Black ? "Black" : "White"; +} +static std::string toString(const gameNet::Seat seat) { assert(isPlayer(seat)); return seat == gameNet::Seat::Black ? "Black" : "White"; } + void MockClient::onGameUpdate(const gameNet::ServerDelta& event) { const auto seat = toString(event.seat); switch (event.action) { @@ -60,7 +64,7 @@ void MockClient::onGameConfig(const gameNet::ServerGameConfig& event) { } void MockClient::onChatMessage(const gameNet::ServerChat& event) { - std::cout << std::format("[Client] Received message from '{}':{}\n", toString(event.seat), event.message); + std::cout << std::format("[Client] Received message from '{}':{}\n", toString(event.player), event.message); } void MockClient::onDisconnected() { diff --git a/tests/gameNet/mockClient.hpp b/tests/gameNet/mockClient.hpp index 991c6a3..f11144d 100644 --- a/tests/gameNet/mockClient.hpp +++ b/tests/gameNet/mockClient.hpp @@ -19,9 +19,6 @@ class MockClient : public gameNet::IClientHandler { void onChatMessage(const gameNet::ServerChat& event) override; void onDisconnected() override; -private: - std::string toString(gameNet::Seat seat); - private: gameNet::Client m_network; }; diff --git a/tests/gameNet/mockServer.cpp b/tests/gameNet/mockServer.cpp index 8de5d20..c469e47 100644 --- a/tests/gameNet/mockServer.cpp +++ b/tests/gameNet/mockServer.cpp @@ -62,8 +62,10 @@ void MockServer::handleNetworkEvent(gameNet::SessionId sessionId, const gameNet: } void MockServer::handleNetworkEvent(gameNet::SessionId sessionId, const gameNet::ClientChat& event) { + static unsigned messageId = 0u; + const auto seat = m_network.getSeat(sessionId); - m_network.broadcast(gameNet::ServerChat{.seat = seat, .message = event.message}); + m_network.broadcast(gameNet::ServerChat{seat == gameNet::Seat::Black ? Player::Black : Player::White, messageId++, event.message}); } gameNet::Seat MockServer::nextSeat(gameNet::Seat seat) const { diff --git a/tests/gameNet/nwEvents.gtest.cpp b/tests/gameNet/nwEvents.gtest.cpp index b4ddc0d..98c01fd 100644 --- a/tests/gameNet/nwEvents.gtest.cpp +++ b/tests/gameNet/nwEvents.gtest.cpp @@ -105,8 +105,8 @@ TEST(GameNetMessages, ServerToMessage) { {"next", static_cast(gameNet::Seat::White)}, {"status", static_cast(gameNet::GameStatus::WhiteWin)}})); - EXPECT_EQ(json::parse(gameNet::toMessage(gameNet::ServerChat{gameNet::Seat::White, "hi"})), - json({{"type", "chat"}, {"seat", static_cast(gameNet::Seat::White)}, {"message", "hi"}})); + EXPECT_EQ(json::parse(gameNet::toMessage(gameNet::ServerChat{Player::White, 0u, "hi"})), + json({{"type", "chat"}, {"player", static_cast(Player::White)}, {"messageId", 0}, {"message", "hi"}})); } TEST(GameNetMessages, ServerFromMessageValid) { @@ -141,11 +141,12 @@ TEST(GameNetMessages, ServerFromMessageValid) { EXPECT_FALSE(passEvent.coord.has_value()); EXPECT_TRUE(passEvent.captures.empty()); - const auto chat = gameNet::fromServerMessage(R"({"type":"chat","seat":2,"message":"hello,world"})"); + const auto chat = gameNet::fromServerMessage(R"({"type":"chat","player":2,"messageId":0,"message":"hello,world"})"); ASSERT_TRUE(chat.has_value()); ASSERT_TRUE(std::holds_alternative(*chat)); const auto chatEvent = std::get(*chat); - EXPECT_EQ(chatEvent.seat, gameNet::Seat::Black); + EXPECT_EQ(chatEvent.player, Player::White); + EXPECT_EQ(chatEvent.messageId, 0u); EXPECT_EQ(chatEvent.message, "hello,world"); const auto config = gameNet::fromServerMessage(R"({"type":"config","boardSize":13,"komi":6.5,"time":300})"); From 270ab14a8b1cf28f24cdd39aa328730b5f3969d5 Mon Sep 17 00:00:00 2001 From: Oliver Benz Date: Sat, 31 Jan 2026 21:23:51 +0100 Subject: [PATCH 2/6] App,GUI: Basic messaging working. --- src/App/gameServer.cpp | 2 +- src/App/include/app/gameServer.hpp | 9 ++--- src/App/include/app/sessionManager.hpp | 11 +++++- src/App/sessionManager.cpp | 22 +++++++++++- src/gui/GameWidget.cpp | 47 ++++++++++++++++++++++++-- src/gui/GameWidget.hpp | 10 ++++++ 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/App/gameServer.cpp b/src/App/gameServer.cpp index c8127c0..40c2294 100644 --- a/src/App/gameServer.cpp +++ b/src/App/gameServer.cpp @@ -156,7 +156,7 @@ void GameServer::handleNetworkEvent(Player player, const gameNet::ClientResign&) void GameServer::handleNetworkEvent(Player player, const gameNet::ClientChat& event) { m_chatHistory.emplace_back(ChatEntry{player, event.message}); - m_server.broadcast(gameNet::ServerChat{player, m_chatHistory.size(), event.message}); + m_server.broadcast(gameNet::ServerChat{player, static_cast(m_chatHistory.size()), event.message}); } } // namespace go::app diff --git a/src/App/include/app/gameServer.hpp b/src/App/include/app/gameServer.hpp index 19d6c90..aa02c1a 100644 --- a/src/App/include/app/gameServer.hpp +++ b/src/App/include/app/gameServer.hpp @@ -16,10 +16,6 @@ namespace go { namespace app { -struct ChatEntry { - Player player; - std::string message; -}; class GameServer : public gameNet::IServerHandler, public IGameStateListener { public: @@ -44,6 +40,11 @@ class GameServer : public gameNet::IServerHandler, public IGameStateListener { void handleNetworkEvent(Player player, const gameNet::ClientResign& event); void handleNetworkEvent(Player player, const gameNet::ClientChat& event); + struct ChatEntry { + Player player; + std::string message; + }; + private: Game m_game; std::thread m_gameThread; //!< Runs the game loop. diff --git a/src/App/include/app/sessionManager.hpp b/src/App/include/app/sessionManager.hpp index 278bad5..416c625 100644 --- a/src/App/include/app/sessionManager.hpp +++ b/src/App/include/app/sessionManager.hpp @@ -13,6 +13,12 @@ namespace go::app { class GameServer; +struct ChatEntry { + Player player; + unsigned messageId; + std::string message; +}; + //! Gets game stat delta and constructs a local representation of the game. //! Listeners can subscribe to certain signals, get notification when happens. //! Listeners then check which signal and query the updated data from this SessionManager. @@ -43,6 +49,7 @@ class SessionManager : public gameNet::IClientHandler { GameStatus status() const; Board board() const; Player currentPlayer() const; + std::vector getChatSince(unsigned messageId) const; public: // Client listener handlers void onGameUpdate(const gameNet::ServerDelta& event) override; @@ -55,7 +62,9 @@ class SessionManager : public gameNet::IClientHandler { EventHub m_eventHub; Position m_position; - std::vector m_chatHistory; + unsigned m_expectedMessageId{1u}; + std::vector m_chatHistory{}; + std::unique_ptr m_localServer; mutable std::mutex m_stateMutex; }; diff --git a/src/App/sessionManager.cpp b/src/App/sessionManager.cpp index dc07c8c..c116dc3 100644 --- a/src/App/sessionManager.cpp +++ b/src/App/sessionManager.cpp @@ -3,6 +3,7 @@ #include "Logging.hpp" #include "app/gameServer.hpp" +#include #include namespace go::app { @@ -28,6 +29,7 @@ void SessionManager::connect(const std::string& hostIp) { std::lock_guard lock(m_stateMutex); m_position.reset(9u); m_position.setStatus(GameStatus::Ready); + m_expectedMessageId = 1u; m_chatHistory.clear(); } m_localServer.reset(); @@ -41,6 +43,7 @@ void SessionManager::host(unsigned boardSize) { std::lock_guard lock(m_stateMutex); m_position.reset(boardSize); m_position.setStatus(GameStatus::Ready); + m_expectedMessageId = 1u; m_chatHistory.clear(); } @@ -58,6 +61,7 @@ void SessionManager::disconnect() { std::lock_guard lock(m_stateMutex); m_position.reset(9u); + m_expectedMessageId = 1u; m_chatHistory.clear(); } @@ -87,6 +91,15 @@ Player SessionManager::currentPlayer() const { std::lock_guard lock(m_stateMutex); return m_position.getPlayer(); } +std::vector SessionManager::getChatSince(const unsigned messageId) const { + std::lock_guard lock(m_stateMutex); + + // Find first entry with id > messageId + auto it = std::upper_bound(m_chatHistory.begin(), m_chatHistory.end(), messageId, + [](const unsigned value, const ChatEntry& e) { return e.messageId > value; }); + + return {it, m_chatHistory.end()}; +} void SessionManager::onGameUpdate(const gameNet::ServerDelta& event) { std::lock_guard lock(m_stateMutex); @@ -98,12 +111,19 @@ void SessionManager::onGameConfig(const gameNet::ServerGameConfig& event) { } void SessionManager::onChatMessage(const gameNet::ServerChat& event) { std::lock_guard lock(m_stateMutex); - m_chatHistory.push_back(event.message); + + if (event.messageId == m_expectedMessageId) { + m_chatHistory.emplace_back(ChatEntry{event.player, event.messageId, event.message}); + ++m_expectedMessageId; + } else { + // TODO: Handle missing messages & ordered inserts (messageId assumed ordered in GUI) + } m_eventHub.signal(AS_NewChat); } void SessionManager::onDisconnected() { std::lock_guard lock(m_stateMutex); m_position.reset(9u); + m_expectedMessageId = 1u; m_chatHistory.clear(); } diff --git a/src/gui/GameWidget.cpp b/src/gui/GameWidget.cpp index 9e803cf..b86d99e 100644 --- a/src/gui/GameWidget.cpp +++ b/src/gui/GameWidget.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace go::gui { @@ -16,11 +17,13 @@ GameWidget::GameWidget(app::SessionManager& game, QWidget* parent) : QWidget(par // Connect slots connect(m_passButton, &QPushButton::clicked, this, &GameWidget::onPassClicked); connect(m_resignButton, &QPushButton::clicked, this, &GameWidget::onResignClicked); + connect(m_chatSend, &QPushButton::clicked, this, &GameWidget::onSendChat); + connect(m_chatInput, &QLineEdit::returnPressed, this, &GameWidget::onSendChat); // Setup Game Stuff setCurrentPlayerText(); setGameStateText(); - m_game.subscribe(this, app::AS_PlayerChange | app::AS_StateChange); + m_game.subscribe(this, app::AS_PlayerChange | app::AS_StateChange | app::AS_NewChat); } GameWidget::~GameWidget() { @@ -35,6 +38,9 @@ void GameWidget::onAppEvent(const app::AppSignal signal) { case app::AS_StateChange: QMetaObject::invokeMethod(this, [this]() { setGameStateText(); }, Qt::QueuedConnection); break; + case app::AS_NewChat: + QMetaObject::invokeMethod(this, [this]() { appendChatMessages(); }, Qt::QueuedConnection); + break; default: break; } @@ -74,8 +80,19 @@ void GameWidget::buildNetworkLayout() { // Chat auto* chatTab = new QWidget(m_sideTabs); auto* chatLayout = new QVBoxLayout(chatTab); // Title and content below - chatLayout->addWidget(new QLabel("Chat placeholder.", chatTab)); - chatLayout->addStretch(); // Fill space below Label + m_chatList = new QListWidget(chatTab); + m_chatList->setSelectionMode(QAbstractItemView::NoSelection); + m_chatList->setFocusPolicy(Qt::NoFocus); + chatLayout->addWidget(m_chatList, 1); + + auto* chatInputRow = new QWidget(chatTab); + auto* chatInputLayout = new QHBoxLayout(chatInputRow); + chatInputLayout->setContentsMargins(0, 0, 0, 0); + m_chatInput = new QLineEdit(chatInputRow); + m_chatSend = new QPushButton("Send", chatInputRow); + chatInputLayout->addWidget(m_chatInput, 1); + chatInputLayout->addWidget(m_chatSend); + chatLayout->addWidget(chatInputRow); m_sideTabs->addTab(chatTab, "Chat"); contentLayout->addWidget(m_sideTabs, 1); @@ -116,6 +133,18 @@ void GameWidget::setGameStateText() { m_statusLabel->setText(QString::fromStdString(message.at(m_game.status()))); } +void GameWidget::appendChatMessages() { + const auto messageEntries = m_game.getChatSince(m_lastChatMessageId); + for (const auto& entry: messageEntries) { + const auto player = entry.player == Player::Black ? "Black" : "White"; + const auto line = std::format("{}: {}", player, entry.message); + + m_lastChatMessageId = entry.messageId; // Assumes ordered. + m_chatList->addItem(QString::fromStdString(line)); + } + m_chatList->scrollToBottom(); +} + void GameWidget::onPassClicked() { m_game.tryPass(); } @@ -124,4 +153,16 @@ void GameWidget::onResignClicked() { m_game.tryResign(); } +void GameWidget::onSendChat() { + if (!m_chatInput) { + return; + } + const auto text = m_chatInput->text().trimmed(); + if (text.isEmpty()) { + return; + } + m_game.chat(text.toStdString()); + m_chatInput->clear(); +} + } // namespace go::gui diff --git a/src/gui/GameWidget.hpp b/src/gui/GameWidget.hpp index 120b54e..8786370 100644 --- a/src/gui/GameWidget.hpp +++ b/src/gui/GameWidget.hpp @@ -5,6 +5,8 @@ #include "core/game.hpp" #include #include +#include +#include #include #include #include @@ -27,10 +29,12 @@ class GameWidget : public QWidget, public app::IAppSignalListener { void setCurrentPlayerText(); //!< Get current player from game and update the label. void setGameStateText(); //!< Get game state from game and update the label. + void appendChatMessages(); //!< Get new chat messages from game and update the chat list. private: // Slots void onPassClicked(); void onResignClicked(); + void onSendChat(); private: app::SessionManager& m_game; @@ -43,6 +47,12 @@ class GameWidget : public QWidget, public app::IAppSignalListener { QPushButton* m_passButton = nullptr; QPushButton* m_resignButton = nullptr; + + // Chat + QListWidget* m_chatList = nullptr; + QLineEdit* m_chatInput = nullptr; + QPushButton* m_chatSend = nullptr; + unsigned m_lastChatMessageId = 0u; }; } // namespace go::gui From 76d9d15676dd89557789c536c7232db544260342 Mon Sep 17 00:00:00 2001 From: Oliver Benz Date: Sat, 31 Jan 2026 21:27:09 +0100 Subject: [PATCH 3/6] App: Fix invalid usage of new board type. --- src/App/position.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App/position.cpp b/src/App/position.cpp index 0f0963e..9a67b8a 100644 --- a/src/App/position.cpp +++ b/src/App/position.cpp @@ -94,7 +94,7 @@ void Position::updatePosition(const gameNet::ServerDelta& delta) { if (delta.coord) { m_board.place(Coord{delta.coord->x, delta.coord->y}, delta.seat == gameNet::Seat::Black ? Board::Stone::Black : Board::Stone::White); for (const auto c: delta.captures) { - m_board.place({c.x, c.y}, Board::Stone::Empty); + m_board.remove({c.x, c.y}); } } else { Logger().Log(Logging::LogLevel::Warning, "Game delta missing place coordinate; skipping board update."); From 3c5dd3e0f5a2f1f46a336eae797fd14f39712562 Mon Sep 17 00:00:00 2001 From: Oliver Benz Date: Sat, 31 Jan 2026 21:58:11 +0100 Subject: [PATCH 4/6] App: Signal in sessionManager preventing signal deadlocks. --- src/App/include/app/position.hpp | 12 +-- src/App/include/app/sessionManager.hpp | 2 +- src/App/position.cpp | 80 ++++++------------- src/App/sessionManager.cpp | 103 ++++++++++++++++++++----- 4 files changed, 111 insertions(+), 86 deletions(-) diff --git a/src/App/include/app/position.hpp b/src/App/include/app/position.hpp index 14dc1a9..6c7612e 100644 --- a/src/App/include/app/position.hpp +++ b/src/App/include/app/position.hpp @@ -10,11 +10,11 @@ namespace go::app { class Position { public: - Position(EventHub& hub); //!< Hub allows to signal listeners on interesting updates. + Position() = default; void reset(std::size_t boardSize); //!< Reset the position to some default data. - void init(const gameNet::ServerGameConfig& event); //!< Initialize the given position. - void apply(const gameNet::ServerDelta& delta); //!< Apply a delta to the current position if ok. + bool init(const gameNet::ServerGameConfig& event); //!< Initialize the given position. Returns true if it changed state. + bool apply(const gameNet::ServerDelta& delta); //!< Apply a delta to the current position if ok. void setStatus(GameStatus status); //!< Update the status. const Board& getBoard() const; @@ -23,16 +23,12 @@ class Position { private: bool isDeltaApplicable(const gameNet::ServerDelta& delta); //!< Check if the delta is ok to use for the position update. - void updatePosition(const gameNet::ServerDelta& delta); //!< Update the position based on a server delta. - void signalOnAction(gameNet::ServerAction action); //!< Signal listeners. Call after applyDelta. private: unsigned m_moveId{0}; //!< Last move id in game. GameStatus m_status{GameStatus::Idle}; //!< Current status of the game. Player m_player{Player::Black}; Board m_board{9u}; - - EventHub& m_eventHub; //!< Send events when position changes. }; -} // namespace go::app \ No newline at end of file +} // namespace go::app diff --git a/src/App/include/app/sessionManager.hpp b/src/App/include/app/sessionManager.hpp index 416c625..cd0aeb2 100644 --- a/src/App/include/app/sessionManager.hpp +++ b/src/App/include/app/sessionManager.hpp @@ -60,7 +60,7 @@ class SessionManager : public gameNet::IClientHandler { private: gameNet::Client m_network; EventHub m_eventHub; - Position m_position; + Position m_position{}; unsigned m_expectedMessageId{1u}; std::vector m_chatHistory{}; diff --git a/src/App/position.cpp b/src/App/position.cpp index 9a67b8a..76ea6f8 100644 --- a/src/App/position.cpp +++ b/src/App/position.cpp @@ -5,23 +5,16 @@ namespace go::app { -Position::Position(EventHub& hub) : m_eventHub{hub} { -} - void Position::reset(const std::size_t boardSize) { m_moveId = 0u; m_status = GameStatus::Idle; m_player = Player::Black; m_board = Board{boardSize}; - - m_eventHub.signal(AS_BoardChange); - m_eventHub.signal(AS_PlayerChange); - m_eventHub.signal(AS_StateChange); } -void Position::init(const gameNet::ServerGameConfig& event) { +bool Position::init(const gameNet::ServerGameConfig& event) { if (m_status == GameStatus::Active) { - return; + return false; } // TODO: Komi and timer not yet implemented. @@ -29,22 +22,33 @@ void Position::init(const gameNet::ServerGameConfig& event) { m_status = GameStatus::Active; m_player = Player::Black; m_board = Board{event.boardSize}; - - m_eventHub.signal(AS_BoardChange); - m_eventHub.signal(AS_PlayerChange); - m_eventHub.signal(AS_StateChange); + return true; } -void Position::apply(const gameNet::ServerDelta& delta) { - if (isDeltaApplicable(delta)) { - updatePosition(delta); - signalOnAction(delta.action); +bool Position::apply(const gameNet::ServerDelta& delta) { + if (!isDeltaApplicable(delta)) { + return false; + } + + m_moveId = delta.turn; + m_status = delta.status == gameNet::GameStatus::Active ? GameStatus::Active : GameStatus::Done; + m_player = delta.next == gameNet::Seat::Black ? Player::Black : Player::White; + + if (delta.action == gameNet::ServerAction::Place) { + if (delta.coord) { + m_board.place(Coord{delta.coord->x, delta.coord->y}, delta.seat == gameNet::Seat::Black ? Board::Stone::Black : Board::Stone::White); + for (const auto c: delta.captures) { + m_board.remove({c.x, c.y}); + } + } else { + Logger().Log(Logging::LogLevel::Warning, "Game delta missing place coordinate; skipping board update."); + } } + return true; } void Position::setStatus(GameStatus status) { m_status = status; - m_eventHub.signal(AS_StateChange); } @@ -85,42 +89,4 @@ bool Position::isDeltaApplicable(const gameNet::ServerDelta& delta) { return true; } -void Position::updatePosition(const gameNet::ServerDelta& delta) { - m_moveId = delta.turn; - m_status = delta.status == gameNet::GameStatus::Active ? GameStatus::Active : GameStatus::Done; - m_player = delta.next == gameNet::Seat::Black ? Player::Black : Player::White; - - if (delta.action == gameNet::ServerAction::Place) { - if (delta.coord) { - m_board.place(Coord{delta.coord->x, delta.coord->y}, delta.seat == gameNet::Seat::Black ? Board::Stone::Black : Board::Stone::White); - for (const auto c: delta.captures) { - m_board.remove({c.x, c.y}); - } - } else { - Logger().Log(Logging::LogLevel::Warning, "Game delta missing place coordinate; skipping board update."); - } - } -} - -void Position::signalOnAction(gameNet::ServerAction action) { - switch (action) { - case gameNet::ServerAction::Place: - m_eventHub.signal(AS_BoardChange); - m_eventHub.signal(AS_PlayerChange); - break; - case gameNet::ServerAction::Pass: - m_eventHub.signal(AS_PlayerChange); - if (m_status != GameStatus::Active) { - m_eventHub.signal(AS_StateChange); - } - break; - case gameNet::ServerAction::Resign: - m_eventHub.signal(AS_StateChange); - break; - case gameNet::ServerAction::Count: - assert(false); //!< This should already be prohibited by libGameNet. - break; - }; -} - -} // namespace go::app \ No newline at end of file +} // namespace go::app diff --git a/src/App/sessionManager.cpp b/src/App/sessionManager.cpp index c116dc3..96aed54 100644 --- a/src/App/sessionManager.cpp +++ b/src/App/sessionManager.cpp @@ -8,7 +8,7 @@ namespace go::app { -SessionManager::SessionManager() : m_position{m_eventHub} { +SessionManager::SessionManager() { m_network.registerHandler(this); } SessionManager::~SessionManager() { @@ -34,6 +34,10 @@ void SessionManager::connect(const std::string& hostIp) { } m_localServer.reset(); m_network.connect(hostIp); + + m_eventHub.signal(AS_BoardChange); + m_eventHub.signal(AS_PlayerChange); + m_eventHub.signal(AS_StateChange); } void SessionManager::host(unsigned boardSize) { @@ -50,6 +54,10 @@ void SessionManager::host(unsigned boardSize) { m_localServer = std::make_unique(boardSize); m_localServer->start(); m_network.connect("127.0.0.1"); + + m_eventHub.signal(AS_BoardChange); + m_eventHub.signal(AS_PlayerChange); + m_eventHub.signal(AS_StateChange); } void SessionManager::disconnect() { @@ -59,10 +67,16 @@ void SessionManager::disconnect() { m_localServer.reset(); } - std::lock_guard lock(m_stateMutex); - m_position.reset(9u); - m_expectedMessageId = 1u; - m_chatHistory.clear(); + { + std::lock_guard lock(m_stateMutex); + m_position.reset(9u); + m_expectedMessageId = 1u; + m_chatHistory.clear(); + } + + m_eventHub.signal(AS_BoardChange); + m_eventHub.signal(AS_PlayerChange); + m_eventHub.signal(AS_StateChange); } @@ -102,29 +116,78 @@ std::vector SessionManager::getChatSince(const unsigned messageId) co } void SessionManager::onGameUpdate(const gameNet::ServerDelta& event) { - std::lock_guard lock(m_stateMutex); - m_position.apply(event); + GameStatus status = GameStatus::Active; + bool applied = false; + { + std::lock_guard lock(m_stateMutex); + applied = m_position.apply(event); + status = m_position.getStatus(); // For signalling later + } + + if (!applied) { + return; + } + + // Signalling depending on action + switch (event.action) { + case gameNet::ServerAction::Place: + m_eventHub.signal(AS_BoardChange); + m_eventHub.signal(AS_PlayerChange); + break; + case gameNet::ServerAction::Pass: + m_eventHub.signal(AS_PlayerChange); + if (status != GameStatus::Active) { + m_eventHub.signal(AS_StateChange); + } + break; + case gameNet::ServerAction::Resign: + m_eventHub.signal(AS_StateChange); + break; + case gameNet::ServerAction::Count: + assert(false); //!< This should already be prohibited by libGameNet. + break; + }; } void SessionManager::onGameConfig(const gameNet::ServerGameConfig& event) { - std::lock_guard lock(m_stateMutex); - m_position.init(event); + bool initialized = false; + { + std::lock_guard lock(m_stateMutex); + initialized = m_position.init(event); + } + if (!initialized) { + return; + } + m_eventHub.signal(AS_BoardChange); + m_eventHub.signal(AS_PlayerChange); + m_eventHub.signal(AS_StateChange); } void SessionManager::onChatMessage(const gameNet::ServerChat& event) { - std::lock_guard lock(m_stateMutex); + bool appended = false; + { + std::lock_guard lock(m_stateMutex); - if (event.messageId == m_expectedMessageId) { - m_chatHistory.emplace_back(ChatEntry{event.player, event.messageId, event.message}); - ++m_expectedMessageId; - } else { - // TODO: Handle missing messages & ordered inserts (messageId assumed ordered in GUI) + if (event.messageId == m_expectedMessageId) { + m_chatHistory.emplace_back(ChatEntry{event.player, event.messageId, event.message}); + ++m_expectedMessageId; + appended = true; + } else { + // TODO: Handle missing messages & ordered inserts (messageId assumed ordered in GUI) + } + } + if (appended) { + m_eventHub.signal(AS_NewChat); } - m_eventHub.signal(AS_NewChat); } void SessionManager::onDisconnected() { - std::lock_guard lock(m_stateMutex); - m_position.reset(9u); - m_expectedMessageId = 1u; - m_chatHistory.clear(); + { + std::lock_guard lock(m_stateMutex); + m_position.reset(9u); + m_expectedMessageId = 1u; + m_chatHistory.clear(); + } + m_eventHub.signal(AS_BoardChange); + m_eventHub.signal(AS_PlayerChange); + m_eventHub.signal(AS_StateChange); } } // namespace go::app From 71995c2afd6aea89609b33a1989901b88dfe65f9 Mon Sep 17 00:00:00 2001 From: Oliver Benz Date: Sat, 31 Jan 2026 22:22:56 +0100 Subject: [PATCH 5/6] App: Better game state update signal. --- src/App/sessionManager.cpp | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/App/sessionManager.cpp b/src/App/sessionManager.cpp index 96aed54..cdda88d 100644 --- a/src/App/sessionManager.cpp +++ b/src/App/sessionManager.cpp @@ -116,12 +116,14 @@ std::vector SessionManager::getChatSince(const unsigned messageId) co } void SessionManager::onGameUpdate(const gameNet::ServerDelta& event) { - GameStatus status = GameStatus::Active; - bool applied = false; + GameStatus status = GameStatus::Active; + GameStatus previousStatus = GameStatus::Active; + bool applied = false; { std::lock_guard lock(m_stateMutex); - applied = m_position.apply(event); - status = m_position.getStatus(); // For signalling later + previousStatus = m_position.getStatus(); + applied = m_position.apply(event); + status = m_position.getStatus(); // For signalling later } if (!applied) { @@ -136,17 +138,16 @@ void SessionManager::onGameUpdate(const gameNet::ServerDelta& event) { break; case gameNet::ServerAction::Pass: m_eventHub.signal(AS_PlayerChange); - if (status != GameStatus::Active) { - m_eventHub.signal(AS_StateChange); - } break; case gameNet::ServerAction::Resign: - m_eventHub.signal(AS_StateChange); break; case gameNet::ServerAction::Count: assert(false); //!< This should already be prohibited by libGameNet. break; }; + if (previousStatus != status) { + m_eventHub.signal(AS_StateChange); + } } void SessionManager::onGameConfig(const gameNet::ServerGameConfig& event) { bool initialized = false; From 5e7700d3c4b9c7da7f6d28e42cf33aa56ccf7e11 Mon Sep 17 00:00:00 2001 From: Oliver Benz Date: Sat, 31 Jan 2026 22:24:59 +0100 Subject: [PATCH 6/6] App: Handle chat messages received out-of-order. --- src/App/include/app/sessionManager.hpp | 6 ++++-- src/App/sessionManager.cpp | 22 ++++++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/App/include/app/sessionManager.hpp b/src/App/include/app/sessionManager.hpp index cd0aeb2..fdb64a0 100644 --- a/src/App/include/app/sessionManager.hpp +++ b/src/App/include/app/sessionManager.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace go::app { @@ -62,8 +63,9 @@ class SessionManager : public gameNet::IClientHandler { EventHub m_eventHub; Position m_position{}; - unsigned m_expectedMessageId{1u}; - std::vector m_chatHistory{}; + unsigned m_expectedMessageId{1u}; //!< Next expected chat message id. + std::vector m_chatHistory{}; //!< Chat history. + std::unordered_map m_pendingChat{}; //!< Messages received out of order. std::unique_ptr m_localServer; mutable std::mutex m_stateMutex; diff --git a/src/App/sessionManager.cpp b/src/App/sessionManager.cpp index cdda88d..66302ab 100644 --- a/src/App/sessionManager.cpp +++ b/src/App/sessionManager.cpp @@ -31,6 +31,7 @@ void SessionManager::connect(const std::string& hostIp) { m_position.setStatus(GameStatus::Ready); m_expectedMessageId = 1u; m_chatHistory.clear(); + m_pendingChat.clear(); } m_localServer.reset(); m_network.connect(hostIp); @@ -49,6 +50,7 @@ void SessionManager::host(unsigned boardSize) { m_position.setStatus(GameStatus::Ready); m_expectedMessageId = 1u; m_chatHistory.clear(); + m_pendingChat.clear(); } m_localServer = std::make_unique(boardSize); @@ -72,6 +74,7 @@ void SessionManager::disconnect() { m_position.reset(9u); m_expectedMessageId = 1u; m_chatHistory.clear(); + m_pendingChat.clear(); } m_eventHub.signal(AS_BoardChange); @@ -167,12 +170,26 @@ void SessionManager::onChatMessage(const gameNet::ServerChat& event) { { std::lock_guard lock(m_stateMutex); - if (event.messageId == m_expectedMessageId) { + if (event.messageId < m_expectedMessageId) { + // Ignore already seen messages. + } else if (event.messageId == m_expectedMessageId) { m_chatHistory.emplace_back(ChatEntry{event.player, event.messageId, event.message}); ++m_expectedMessageId; appended = true; } else { - // TODO: Handle missing messages & ordered inserts (messageId assumed ordered in GUI) + m_pendingChat.emplace(event.messageId, ChatEntry{event.player, event.messageId, event.message}); + } + + // Try insterting pending chat messages to history. + while (true) { + auto it = m_pendingChat.find(m_expectedMessageId); + if (it == m_pendingChat.end()) { + break; + } + m_chatHistory.emplace_back(it->second); + m_pendingChat.erase(it); + ++m_expectedMessageId; + appended = true; } } if (appended) { @@ -185,6 +202,7 @@ void SessionManager::onDisconnected() { m_position.reset(9u); m_expectedMessageId = 1u; m_chatHistory.clear(); + m_pendingChat.clear(); } m_eventHub.signal(AS_BoardChange); m_eventHub.signal(AS_PlayerChange);