From b887f15662095b3587a02df32eb3eb60fc3322d4 Mon Sep 17 00:00:00 2001 From: bumpsoo Date: Tue, 3 Feb 2026 09:44:08 +0000 Subject: [PATCH] init --- .gitignore | 13 ++++ CMakeLists.txt | 32 ++++++++ README.md | 51 +++++++++++++ compose.yml | 12 +++ include/DatabaseManager.h | 42 +++++++++++ include/Logger.h | 15 ++++ include/NetworkService.h | 25 +++++++ include/Packet.h | 83 +++++++++++++++++++++ include/PacketHandler.h | 15 ++++ include/Session.h | 43 +++++++++++ include/SessionManager.h | 20 +++++ include/SwordLogic.h | 63 ++++++++++++++++ schema.sql | 10 +++ setup.sh | 18 +++++ src/DatabaseManager.cpp | 152 ++++++++++++++++++++++++++++++++++++++ src/NetworkService.cpp | 32 ++++++++ src/PacketHandler.cpp | 132 +++++++++++++++++++++++++++++++++ src/Session.cpp | 101 +++++++++++++++++++++++++ src/SessionManager.cpp | 38 ++++++++++ src/main.cpp | 45 +++++++++++ tests/Client.cpp | 96 ++++++++++++++++++++++++ 21 files changed, 1038 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 compose.yml create mode 100644 include/DatabaseManager.h create mode 100644 include/Logger.h create mode 100644 include/NetworkService.h create mode 100644 include/Packet.h create mode 100644 include/PacketHandler.h create mode 100644 include/Session.h create mode 100644 include/SessionManager.h create mode 100644 include/SwordLogic.h create mode 100644 schema.sql create mode 100755 setup.sh create mode 100644 src/DatabaseManager.cpp create mode 100644 src/NetworkService.cpp create mode 100644 src/PacketHandler.cpp create mode 100644 src/Session.cpp create mode 100644 src/SessionManager.cpp create mode 100644 src/main.cpp create mode 100644 tests/Client.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7cb287 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Build artifacts +build/ + + +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +compile_commands.json + +.cache/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e4d571b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.10) +project(SocketServer VERSION 1.0) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED True) + +add_compile_options(-Wall -Wextra -Wpedantic) + +# Boost 및 의존성 찾기 +find_package(Boost REQUIRED COMPONENTS system) +find_package(OpenSSL REQUIRED) + +# 컴파일 명령 추출 활성화 (LSP/IDE 용) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +add_executable(Server + src/main.cpp + src/NetworkService.cpp + src/Session.cpp + src/PacketHandler.cpp + src/SessionManager.cpp + src/DatabaseManager.cpp +) + +target_include_directories(Server PRIVATE include) +target_link_libraries(Server PRIVATE Boost::system OpenSSL::SSL OpenSSL::Crypto) + +add_executable(Client + tests/Client.cpp +) +target_include_directories(Client PRIVATE include) +target_link_libraries(Client PRIVATE Boost::system OpenSSL::SSL OpenSSL::Crypto) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1b150b --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# C++20 게임 서버 (SocketServer) + +## 개요 +이 프로젝트는 Modern C++ (C++20)와 Boost.Asio를 사용한 게임 서버 코어를 보여줍니다. +비동기 I/O, 멀티스레딩, 패킷 직렬화 및 글로벌 세션 관리 시스템을 특징으로 합니다. + +## 주요 기능 +- C++20: std::span, std::jthread, std::format (준비됨) 및 Concepts 활용. +- Boost.Asio: 확장 가능한 네트워킹을 위한 Proactor 패턴 적용. +- 패킷 처리: 효율적인 헤더/바디 분리 및 디스패칭. +- 스레드 안전성: 뮤텍스(mutex)를 사용한 공유 자원 보호. +- 글로벌 세션 관리: 모든 접속자를 한곳에서 관리하고 브로드캐스팅하는 시스템. +- 검 키우기 컨텐츠: MySQL 연동을 통한 유저 데이터 영속성 및 강화 로직 + +## 사전 요구 사항 (테스트 환경, debian 13) +- g++ 14.2.0 이상 +- CMake 3.31.6 이상 +- Boost Libraries 1.83.0 이상 +- OpenSSL 3.5.4 이상 +- MariaDB Client Library 11.8.3 이상 + +## 환경 설정 + +```bash +./setup.sh +``` + +## 빌드 + +```bash +cmake --build build +``` + +## 실행 방법 +1. 서버 시작: + ```bash + ./Server + ``` +2. 클라이언트 실행: + ```bash + ./Client + ``` + +## 아키텍처 +- NetworkService: io_context 및 스레드 풀 관리. +- Session: 개별 클라이언트 연결 및 비동기 읽기/쓰기 루프 처리. +- PacketHandler: 패킷 ID에 따른 로직 디스패칭. +- SessionManager: 연결된 모든 클라이언트를 글로벌하게 관리하고 메시지를 브로드캐스팅하는 싱글톤 관리자. +- DatabaseManager: Boost.MySQL을 사용한 비동기 데이터베이스 연동 및 데이터 영속화 관리. +- Logger: 멀티스레드 환경에서 안전한 로그 출력을 위한 스레드 세이프 로거. +- Packet: #pragma pack(1)을 사용한 효율적인 바이너리 프로토콜 정의. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..ce6f2a9 --- /dev/null +++ b/compose.yml @@ -0,0 +1,12 @@ +services: + db: + image: mysql:8.0 + container_name: socket_server_db + restart: always + environment: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: socket_server + ports: + - "33306:3306" + volumes: + - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql diff --git a/include/DatabaseManager.h b/include/DatabaseManager.h new file mode 100644 index 0000000..0e84028 --- /dev/null +++ b/include/DatabaseManager.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class DatabaseManager { +public: + static DatabaseManager &GetInstance(); + + bool Init(boost::asio::io_context &io_context, const std::string &host, + uint16_t port, const std::string &user, const std::string &password, + const std::string &db); + + struct UserData { + std::string nickname; + uint64_t gold; + uint32_t swordLevel; + }; + + boost::asio::awaitable LoadUser(std::string nickname); + boost::asio::awaitable SaveUser(std::string nickname, uint64_t gold, + uint32_t swordLevel); + +private: + DatabaseManager() = default; + + // 비동기 Mutex 역할을 할 함수들 + boost::asio::awaitable Lock(); + void Unlock(); + + std::unique_ptr conn_; + std::unique_ptr> + strand_; + + // 비동기 락 시스템을 위한 상태 변수 + bool is_locked_ = false; + std::queue> waiting_queue_; +}; diff --git a/include/Logger.h b/include/Logger.h new file mode 100644 index 0000000..13d247a --- /dev/null +++ b/include/Logger.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +class Logger { +public: + // 가변 인자 템플릿과 Fold Expression을 사용하여 여러 인자를 안전하게 출력 + template static void Log(Args &&...args) { + std::lock_guard lock(mutex_); + (std::cout << ... << std::forward(args)) << std::endl; + } + +private: + static inline std::mutex mutex_; +}; diff --git a/include/NetworkService.h b/include/NetworkService.h new file mode 100644 index 0000000..4a3d27a --- /dev/null +++ b/include/NetworkService.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +using boost::asio::ip::tcp; + +class NetworkService { +public: + NetworkService(uint16_t port, int threadCount); + ~NetworkService(); + + void Run(); + void Stop(); + +private: + void DoAccept(); + + boost::asio::io_context io_context_; + std::vector threads_; + tcp::acceptor acceptor_; + boost::asio::executor_work_guard + work_guard_; +}; diff --git a/include/Packet.h b/include/Packet.h new file mode 100644 index 0000000..dd65c84 --- /dev/null +++ b/include/Packet.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include + +// 표준 헤더 크기: 2바이트 크기 + 2바이트 ID +#pragma pack(push, 1) +struct PacketHeader { + uint16_t size; + uint16_t id; +}; +#pragma pack(pop) + +// 처리를 쉽게 하기 위한 단순 패킷 래퍼 +struct Packet { + PacketHeader header; + // Payload 데이터 (벡터 또는 문자열) + std::vector payload; +}; + +enum class PacketID : uint16_t { + Ping = 1, + // 로그인 요청 (PKT_CS_Login) + Login = 10, + Chat = 20, + + // 검 키우기 관련 패킷 + // 강화 요청 + CS_UpgradeSword = 30, + // 강화 결과 응답 + SC_UpgradeResult = 31, + // 판매 요청 + CS_SellSword = 35, + // 판매 결과 응답 + SC_SellResult = 36, + // 랭킹 조회 요청 + CS_RankingRequest = 40, + // 랭킹 리스트 응답 + SC_RankingList = 41, +}; + +// 게임 규칙 관련 상수 +namespace GameConfig { +const uint32_t MAX_SWORD_LEVEL = 20; +const uint64_t INITIAL_GOLD = 10000; +} // namespace GameConfig + +// 고정 크기 데이터 전송을 위한 구조체 정의 +#pragma pack(push, 1) +struct PKT_CS_Login { + // 유저 닉네임 (최대 32자) + char nickname[32]; +}; + +struct PKT_SC_UpgradeResult { + // 0: 파괴, 1: 성공, 2: 실패 + uint8_t result; + // 현재 강화 레벨 + uint32_t currentLevel; + // 현재 보유 골드 + uint64_t currentGold; +}; + +struct PKT_SC_SellResult { + // 판매 후 획득 골드 + uint64_t earnedGold; + // 현재 총 골드 + uint64_t totalGold; +}; + +struct RankingEntry { + // 유저 이름 (최대 32자) + char username[32]; + // 검 레벨 + uint32_t swordLevel; +}; + +struct PKT_SC_RankingList { + // 랭킹 리스트 개수 + uint32_t count; + // 이후 RankingEntry[count] 만큼 데이터가 따라옴 +}; +#pragma pack(pop) diff --git a/include/PacketHandler.h b/include/PacketHandler.h new file mode 100644 index 0000000..4af426f --- /dev/null +++ b/include/PacketHandler.h @@ -0,0 +1,15 @@ +#pragma once + +#include "Packet.h" +#include "Session.h" +#include +#include + +class Session; + +class PacketHandler { +public: + // 패킷 종류에 따라 비동기 로직 디스패칭 (코루틴 방식) + static boost::asio::awaitable + HandlePacket(std::shared_ptr session, const Packet packet); +}; diff --git a/include/Session.h b/include/Session.h new file mode 100644 index 0000000..b360056 --- /dev/null +++ b/include/Session.h @@ -0,0 +1,43 @@ +#pragma once + +#include "Packet.h" +#include +#include +#include +#include + +using boost::asio::ip::tcp; + +class Session : public std::enable_shared_from_this { +public: + Session(tcp::socket socket); + ~Session(); + + void Start(); + void Send(std::span data); + + void SetNickname(const std::string &nickname); + const std::string &GetNickname() const; + + void SetGold(uint64_t gold); + uint64_t GetGold() const; + + void SetSwordLevel(uint32_t level); + uint32_t GetSwordLevel() const; + +private: + void DoReadHeader(); + void DoReadBody(); + void DoWrite(); + + tcp::socket socket_; + PacketHeader packetHeader_; + std::vector packetBody_; + + // 스레드 안전성과 순서 보장을 위한 출력 패킷 큐 + std::deque> writeQueue_; + + std::string nickname_; + uint64_t gold_ = 0; + uint32_t swordLevel_ = 0; +}; diff --git a/include/SessionManager.h b/include/SessionManager.h new file mode 100644 index 0000000..83a778d --- /dev/null +++ b/include/SessionManager.h @@ -0,0 +1,20 @@ +#pragma once + +#include "Session.h" +#include +#include +#include +#include + +class SessionManager { +public: + static SessionManager &GetInstance(); + + void Join(std::shared_ptr session); + void Leave(std::shared_ptr session); + void Broadcast(PacketHeader header, std::span body); + +private: + std::mutex mutex_; + std::set> sessions_; +}; diff --git a/include/SwordLogic.h b/include/SwordLogic.h new file mode 100644 index 0000000..161f86b --- /dev/null +++ b/include/SwordLogic.h @@ -0,0 +1,63 @@ +#pragma once + +#include "Packet.h" +#include +#include + +class SwordLogic { +public: + // 강화 비용 계산 + static uint64_t GetUpgradeCost(uint32_t currentLevel) { + // 0강은 무료 + if (currentLevel == 0) + return 0; + // 레벨이 오를수록 비용 증가 + return (currentLevel * 1000) + 500; + } + + // 판매 가격 계산 + static uint64_t GetSellPrice(uint32_t currentLevel) { + if (currentLevel == 0) + return 100; + // 성공 횟수에 비례하여 상승 + return (currentLevel * 2000) + 1000; + } + + // 강화 시도 결과 계산 (0: 파괴, 1: 성공, 2: 실패) + static uint8_t TryUpgrade(uint32_t currentLevel) { + // 최대 레벨 도달 시 실패 처리 + if (currentLevel >= GameConfig::MAX_SWORD_LEVEL) + return 2; + + static std::random_device rd; + static std::mt19937 gen(rd()); + std::uniform_int_distribution dis(1, 100); + int randVal = dis(gen); + + // 확률 설정 + int successProb = 0; + int destroyProb = 0; + + if (currentLevel < 10) { + // 0~9강: 성공 확률 높음, 파괴 없음 + // 90% ~ 45% + successProb = 90 - (currentLevel * 5); + destroyProb = 0; + } else { + // 10~19강: 성공 확률 낮음, 파괴 존재 + // 40% ~ 4% + successProb = 40 - ((currentLevel - 10) * 4); + // 5% ~ 23% + destroyProb = 5 + ((currentLevel - 10) * 2); + } + + // 성공 + if (randVal <= successProb) + return 1; + // 파괴 + if (randVal <= (successProb + destroyProb)) + return 0; + // 실패 + return 2; + } +}; diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..1f3329e --- /dev/null +++ b/schema.sql @@ -0,0 +1,10 @@ +CREATE DATABASE IF NOT EXISTS socket_server; +USE socket_server; + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(32) UNIQUE NOT NULL, + gold BIGINT UNSIGNED DEFAULT 10000, + sword_level INT DEFAULT 0, + last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..60eaa56 --- /dev/null +++ b/setup.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +echo "Starting project setup..." + +if [ ! -d "build" ]; then + mkdir build + echo "Created build directory." +fi + +cd build +cmake .. -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +echo "CMake configuration complete." + +cd .. +ln -sf build/compile_commands.json compile_commands.json + diff --git a/src/DatabaseManager.cpp b/src/DatabaseManager.cpp new file mode 100644 index 0000000..dff989c --- /dev/null +++ b/src/DatabaseManager.cpp @@ -0,0 +1,152 @@ +#include "DatabaseManager.h" +#include "Logger.h" +#include +#include +#include + +DatabaseManager &DatabaseManager::GetInstance() { + static DatabaseManager instance; + return instance; +} + +bool DatabaseManager::Init(boost::asio::io_context &io_context, + const std::string &host, uint16_t port, + const std::string &user, const std::string &password, + const std::string &db) { + try { + strand_ = std::make_unique< + boost::asio::strand>( + io_context.get_executor()); + + boost::asio::ip::tcp::resolver resolver(io_context.get_executor()); + auto endpoints = resolver.resolve(host, std::to_string(port)); + + boost::mysql::handshake_params params(user, password, db); + static boost::asio::ssl::context ssl_ctx( + boost::asio::ssl::context::tlsv12_client); + + conn_ = std::make_unique( + io_context.get_executor(), ssl_ctx); + conn_->connect(*endpoints.begin(), params); + + Logger::Log("Database 연결 성공 (AsyncLock 적용): ", host, ":", port); + return true; + } catch (const std::exception &e) { + Logger::Log("Database 연결 실패: ", e.what()); + return false; + } +} + +// 비동기 락 획득 (이미 락이 걸려있으면 대기열에 들어감) +boost::asio::awaitable DatabaseManager::Lock() { + auto executor = co_await boost::asio::this_coro::executor; + + // 이 체크 로직 자체가 strand 위에서 안전하게 돌아야 함 + co_await boost::asio::post( + boost::asio::bind_executor(*strand_, boost::asio::use_awaitable)); + + if (!is_locked_) { + is_locked_ = true; + // 즉시 획득 + co_return; + } + + // 이미 잠겨있으면 타이머를 만들어서 대기열에 추가 (비동기 대기) + auto timer = std::make_shared( + executor, std::chrono::steady_clock::time_point::max()); + waiting_queue_.push(timer); + + try { + co_await timer->async_wait(boost::asio::use_awaitable); + } catch (...) { + // 타이머 취소(Unlock에서 호출함)되면 성공적으로 락을 획득한 것으로 간주 + } + co_return; +} + +// 비동기 락 해제 (대기열에 다음 사람이 있으면 깨워줌) +void DatabaseManager::Unlock() { + boost::asio::dispatch(*strand_, [this]() { + if (waiting_queue_.empty()) { + is_locked_ = false; + } else { + auto next = waiting_queue_.front(); + waiting_queue_.pop(); + // 다음 대기자를 깨움 + next->cancel(); + } + }); +} + +boost::asio::awaitable +DatabaseManager::LoadUser(std::string nickname) { + // 줄 서기 + co_await Lock(); + + try { + if (!conn_) { + Unlock(); + co_return UserData{nickname, 10000, 0}; + } + + boost::mysql::results result; + boost::mysql::statement stmt = co_await conn_->async_prepare_statement( + "SELECT username, gold, sword_level FROM users WHERE username = ?", + boost::asio::use_awaitable); + co_await conn_->async_execute(stmt.bind(nickname), result, + boost::asio::use_awaitable); + + if (result.rows().empty()) { + boost::mysql::statement ins_stmt = + co_await conn_->async_prepare_statement( + "INSERT INTO users (username, gold, sword_level) VALUES (?, " + "10000, 0)", + boost::asio::use_awaitable); + co_await conn_->async_execute(ins_stmt.bind(nickname), result, + boost::asio::use_awaitable); + Logger::Log("새로운 유저 탄생 (DB): ", nickname); + Unlock(); + co_return UserData{nickname, 10000, 0}; + } else { + auto row = result.rows().at(0); + UserData data; + data.nickname = row.at(0).as_string(); + data.gold = row.at(1).as_uint64(); + data.swordLevel = (uint32_t)row.at(2).as_int64(); + Unlock(); + co_return data; + } + } catch (const std::exception &e) { + Logger::Log("LoadUser 에러: ", e.what()); + Unlock(); + co_return UserData{nickname, 10000, 0}; + } +} + +boost::asio::awaitable DatabaseManager::SaveUser(std::string nickname, + uint64_t gold, + uint32_t swordLevel) { + // 줄 서기 + co_await Lock(); + + try { + if (!conn_) { + Unlock(); + co_return; + } + + boost::mysql::results result; + boost::mysql::statement stmt = co_await conn_->async_prepare_statement( + "UPDATE users SET gold = ?, sword_level = ? WHERE username = ?", + boost::asio::use_awaitable); + co_await conn_->async_execute( + stmt.bind(gold, (int64_t)swordLevel, nickname), result, + boost::asio::use_awaitable); + } catch (const std::exception &e) { + Logger::Log("SaveUser 에러: ", e.what()); + } + + // 다음 사람 들어오세요 + Unlock(); + co_return; +} diff --git a/src/NetworkService.cpp b/src/NetworkService.cpp new file mode 100644 index 0000000..2a13784 --- /dev/null +++ b/src/NetworkService.cpp @@ -0,0 +1,32 @@ +#include "NetworkService.h" +#include "Logger.h" +#include "Session.h" + +NetworkService::NetworkService(uint16_t port, int threadCount) + : acceptor_(io_context_, tcp::endpoint(tcp::v4(), port)), + work_guard_(boost::asio::make_work_guard(io_context_)) { + threads_.reserve(threadCount); + for (int i = 0; i < threadCount; ++i) { + threads_.emplace_back([this]() { io_context_.run(); }); + } +} + +NetworkService::~NetworkService() { Stop(); } + +void NetworkService::Run() { + Logger::Log("서버가 포트 ", acceptor_.local_endpoint().port(), + "에서 리스닝을 시작했습니다."); + DoAccept(); +} + +void NetworkService::Stop() { io_context_.stop(); } + +void NetworkService::DoAccept() { + acceptor_.async_accept( + [this](boost::system::error_code ec, tcp::socket socket) { + if (!ec) { + std::make_shared(std::move(socket))->Start(); + } + DoAccept(); + }); +} diff --git a/src/PacketHandler.cpp b/src/PacketHandler.cpp new file mode 100644 index 0000000..6354698 --- /dev/null +++ b/src/PacketHandler.cpp @@ -0,0 +1,132 @@ +#include "PacketHandler.h" +#include "DatabaseManager.h" +#include "Logger.h" +#include "SessionManager.h" +#include "SwordLogic.h" +#include +#include +#include + +boost::asio::awaitable +PacketHandler::HandlePacket(std::shared_ptr session, + const Packet packet) { + switch (static_cast(packet.header.id)) { + case PacketID::Ping: { + Logger::Log("Ping 수신됨"); + session->Send(std::span( + reinterpret_cast(&packet.header), + sizeof(PacketHeader))); + } break; + + case PacketID::Login: { + if (packet.payload.size() < sizeof(PKT_CS_Login)) { + Logger::Log("로그인 패킷 크기가 올바르지 않습니다."); + co_return; + } + + const PKT_CS_Login *loginPkt = + reinterpret_cast(packet.payload.data()); + std::string nickname(loginPkt->nickname); + + // DB에서 유저 정보 로드 (co_await 활용!) + auto userData = co_await DatabaseManager::GetInstance().LoadUser(nickname); + + session->SetNickname(userData.nickname); + session->SetGold(userData.gold); + session->SetSwordLevel(userData.swordLevel); + + Logger::Log("클라이언트 로그인: ", nickname, " (Gold: ", session->GetGold(), + ", Level: ", session->GetSwordLevel(), ")"); + + SessionManager::GetInstance().Join(session); + } break; + + case PacketID::CS_UpgradeSword: { + uint32_t currentLevel = session->GetSwordLevel(); + uint64_t cost = SwordLogic::GetUpgradeCost(currentLevel); + uint64_t currentGold = session->GetGold(); + + PKT_SC_UpgradeResult res; + if (currentGold < cost) { + // 골드 부족 + res.result = 2; + } else { + session->SetGold(currentGold - cost); + uint8_t result = SwordLogic::TryUpgrade(currentLevel); + res.result = result; + + // 성공 + if (result == 1) { + session->SetSwordLevel(currentLevel + 1); + // 파괴 + } else if (result == 0) { + session->SetSwordLevel(0); + } + + co_await DatabaseManager::GetInstance().SaveUser( + session->GetNickname(), session->GetGold(), session->GetSwordLevel()); + } + + res.currentLevel = session->GetSwordLevel(); + res.currentGold = session->GetGold(); + + PacketHeader header; + header.id = static_cast(PacketID::SC_UpgradeResult); + header.size = sizeof(PKT_SC_UpgradeResult); + + std::vector buffer(sizeof(PacketHeader) + + sizeof(PKT_SC_UpgradeResult)); + std::memcpy(buffer.data(), &header, sizeof(PacketHeader)); + std::memcpy(buffer.data() + sizeof(PacketHeader), &res, + sizeof(PKT_SC_UpgradeResult)); + + session->Send(buffer); + + Logger::Log("강화 시도 [", session->GetNickname(), "]: ", (int)res.result, + " (레벨: ", currentLevel, "->", res.currentLevel, ")"); + } break; + + case PacketID::CS_SellSword: { + uint32_t currentLevel = session->GetSwordLevel(); + uint64_t price = SwordLogic::GetSellPrice(currentLevel); + uint64_t newGold = session->GetGold() + price; + + session->SetGold(newGold); + session->SetSwordLevel(0); + + co_await DatabaseManager::GetInstance().SaveUser( + session->GetNickname(), session->GetGold(), session->GetSwordLevel()); + + PKT_SC_SellResult res; + res.earnedGold = price; + res.totalGold = newGold; + + PacketHeader header; + header.id = static_cast(PacketID::SC_SellResult); + header.size = sizeof(PKT_SC_SellResult); + + std::vector buffer(sizeof(PacketHeader) + + sizeof(PKT_SC_SellResult)); + std::memcpy(buffer.data(), &header, sizeof(PacketHeader)); + std::memcpy(buffer.data() + sizeof(PacketHeader), &res, + sizeof(PKT_SC_SellResult)); + + session->Send(buffer); + + Logger::Log("검 판매 [", session->GetNickname(), "]: ", price, + " 골드 획득"); + } break; + + case PacketID::Chat: { + std::string msg(packet.payload.begin(), packet.payload.end()); + Logger::Log("채팅 [", session->GetNickname(), "]: ", msg); + SessionManager::GetInstance().Broadcast(packet.header, packet.payload); + } break; + + default: + Logger::Log("알 수 없는 패킷 ID: ", packet.header.id); + break; + } + + co_return; +} diff --git a/src/Session.cpp b/src/Session.cpp new file mode 100644 index 0000000..44bd80c --- /dev/null +++ b/src/Session.cpp @@ -0,0 +1,101 @@ +#include "Session.h" +#include "PacketHandler.h" +#include "SessionManager.h" +#include +#include + +Session::Session(tcp::socket socket) : socket_(std::move(socket)) {} + +Session::~Session() { + // 필요한 경우 정리 작업 수행 +} + +void Session::SetNickname(const std::string &nickname) { nickname_ = nickname; } +const std::string &Session::GetNickname() const { return nickname_; } + +void Session::SetGold(uint64_t gold) { gold_ = gold; } +uint64_t Session::GetGold() const { return gold_; } + +void Session::SetSwordLevel(uint32_t level) { swordLevel_ = level; } +uint32_t Session::GetSwordLevel() const { return swordLevel_; } + +void Session::Start() { DoReadHeader(); } + +void Session::Send(std::span data) { + auto self(shared_from_this()); + boost::asio::post( + socket_.get_executor(), + [this, self, + msg = std::vector(data.begin(), data.end())]() mutable { + bool writeInProgress = !writeQueue_.empty(); + writeQueue_.push_back(std::move(msg)); + if (!writeInProgress) { + DoWrite(); + } + }); +} + +void Session::DoReadHeader() { + auto self(shared_from_this()); + boost::asio::async_read( + socket_, boost::asio::buffer(&packetHeader_, sizeof(PacketHeader)), + [this, self](boost::system::error_code ec, std::size_t /*length*/) { + if (!ec) { + // 바디 크기 결정 + // 안전 확인: 메모리 고갈을 방지하기 위해 최대 패킷 크기 제한 + if (packetHeader_.size > 1024 * 10) { + socket_.close(); + return; + } + + packetBody_.resize(packetHeader_.size); + DoReadBody(); + } else { + // 연결 끊김 또는 에러 발생 시 매니저에서 제거 + SessionManager::GetInstance().Leave(self); + } + }); +} + +void Session::DoReadBody() { + auto self(shared_from_this()); + boost::asio::async_read( + socket_, boost::asio::buffer(packetBody_.data(), packetBody_.size()), + [this, self](boost::system::error_code ec, std::size_t /*length*/) { + if (!ec) { + // 논리 패킷 구성 + Packet packet; + packet.header = packetHeader_; + packet.payload = packetBody_; + + // 패킷 핸들러 코루틴 실행 + boost::asio::co_spawn( + socket_.get_executor(), + PacketHandler::HandlePacket(self, std::move(packet)), + boost::asio::detached); + + // 계속해서 읽기 + DoReadHeader(); + } else { + // 에러 발생 시 매니저에서 제거 + SessionManager::GetInstance().Leave(self); + } + }); +} + +void Session::DoWrite() { + auto self(shared_from_this()); + boost::asio::async_write( + socket_, boost::asio::buffer(writeQueue_.front()), + [this, self](boost::system::error_code ec, std::size_t /*length*/) { + if (!ec) { + writeQueue_.pop_front(); + if (!writeQueue_.empty()) { + DoWrite(); + } + } else { + // 에러 발생 시 매니저에서 제거 + SessionManager::GetInstance().Leave(self); + } + }); +} diff --git a/src/SessionManager.cpp b/src/SessionManager.cpp new file mode 100644 index 0000000..be45c19 --- /dev/null +++ b/src/SessionManager.cpp @@ -0,0 +1,38 @@ +#include "SessionManager.h" +#include "Logger.h" + +SessionManager &SessionManager::GetInstance() { + static SessionManager instance; + return instance; +} + +void SessionManager::Join(std::shared_ptr session) { + std::lock_guard lock(mutex_); + sessions_.insert(session); + Logger::Log("클라이언트[", session->GetNickname(), + "]가 입장했습니다. 총 세션 수: ", sessions_.size()); +} + +void SessionManager::Leave(std::shared_ptr session) { + std::lock_guard lock(mutex_); + if (sessions_.erase(session) > 0) { + Logger::Log("클라이언트[", session->GetNickname(), + "]가 퇴장했습니다. 총 세션 수: ", sessions_.size()); + } +} + +void SessionManager::Broadcast(PacketHeader header, + std::span body) { + std::lock_guard lock(mutex_); + + // 패킷 직렬화 수행 (헤더 + 바디) + std::vector buffer(sizeof(PacketHeader) + body.size()); + std::memcpy(buffer.data(), &header, sizeof(PacketHeader)); + std::memcpy(buffer.data() + sizeof(PacketHeader), body.data(), body.size()); + + for (auto &session : sessions_) { + // session::send에 span을 전달해야 함. 벡터 자체를 전달할 수도 있지만 우리 + // Send는 span을 인자로 받음. Session::Send는 span을 받아 복사본을 생성함. + session->Send(buffer); + } +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..ffdd3ff --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,45 @@ +#include "DatabaseManager.h" +#include "Logger.h" +#include "NetworkService.h" +#include + +int main() { + try { + uint16_t port = 30000; + uint32_t threadCount = std::thread::hardware_concurrency(); + if (threadCount == 0) + threadCount = 1; + + boost::asio::io_context main_context; + + if (!DatabaseManager::GetInstance().Init(main_context, "127.0.0.1", 33306, + "root", "root_password", + "socket_server")) { + Logger::Log("경고: 초기 DB 연결에 실패했습니다. (서버는 계속 실행됨)"); + } + + NetworkService server(port, threadCount); + server.Run(); + + boost::asio::signal_set signals(main_context, SIGINT, SIGTERM); + + Logger::Log("서버가 실행 중입니다. (종료: Ctrl+C)"); + + signals.async_wait( + [&server, &main_context](const boost::system::error_code &error, + int signal_number) { + if (!error) { + Logger::Log("\n종료 시그널 수신 (", signal_number, + "). 서버를 중지합니다..."); + server.Stop(); + main_context.stop(); + } + }); + + main_context.run(); + } catch (std::exception &e) { + Logger::Log("Exception: ", e.what()); + } + + return 0; +} diff --git a/tests/Client.cpp b/tests/Client.cpp new file mode 100644 index 0000000..7029dbd --- /dev/null +++ b/tests/Client.cpp @@ -0,0 +1,96 @@ +#include "Packet.h" +#include +#include +#include +#include + +using boost::asio::ip::tcp; + +void SendPacket(tcp::socket &socket, PacketID id, const void *payload = nullptr, + size_t payloadSize = 0) { + PacketHeader header; + header.id = static_cast(id); + header.size = static_cast(payloadSize); + + std::vector buffer(sizeof(PacketHeader) + payloadSize); + std::memcpy(buffer.data(), &header, sizeof(PacketHeader)); + if (payload && payloadSize > 0) { + std::memcpy(buffer.data() + sizeof(PacketHeader), payload, payloadSize); + } + + boost::asio::write(socket, boost::asio::buffer(buffer)); +} + +int main(int argc, char *argv[]) { + if (argc < 2) { + std::cout << "사용법: " << argv[0] << " <닉네임>" << std::endl; + return 1; + } + + std::string nickname = argv[1]; + + try { + boost::asio::io_context io_context; + tcp::socket socket(io_context); + tcp::resolver resolver(io_context); + boost::asio::connect(socket, resolver.resolve("127.0.0.1", "30000")); + + std::cout << "서버에 연결되었습니다! (닉네임: " << nickname << ")" + << std::endl; + + // 1. 로그인 패킷 전송 + PKT_CS_Login loginPkt; + std::memset(loginPkt.nickname, 0, sizeof(loginPkt.nickname)); + std::strncpy(loginPkt.nickname, nickname.c_str(), + sizeof(loginPkt.nickname) - 1); + SendPacket(socket, PacketID::Login, &loginPkt, sizeof(loginPkt)); + std::cout << "로그인 요청 보냄" << std::endl; + + // 2. 강화 테스트 (5번 시도) + for (int i = 0; i < 5; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + SendPacket(socket, PacketID::CS_UpgradeSword); + std::cout << i + 1 << "번째 강화 시도..." << std::endl; + + // 결과 수신 + PacketHeader resHeader; + boost::asio::read(socket, + boost::asio::buffer(&resHeader, sizeof(resHeader))); + + if (resHeader.id == static_cast(PacketID::SC_UpgradeResult)) { + PKT_SC_UpgradeResult res; + boost::asio::read(socket, boost::asio::buffer(&res, sizeof(res))); + + std::string resultStr = + (res.result == 1) ? "성공" : (res.result == 0 ? "파괴!!!" : "실패"); + std::cout << ">> 강화 결과: " << resultStr + << " (현재레벨: " << res.currentLevel + << ", 남은골드: " << res.currentGold << ")" << std::endl; + } + } + + // 3. 판매 테스트 + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + SendPacket(socket, PacketID::CS_SellSword); + std::cout << "검 판매 시도..." << std::endl; + + // 결과 수신 + PacketHeader sellResHeader; + boost::asio::read( + socket, boost::asio::buffer(&sellResHeader, sizeof(sellResHeader))); + if (sellResHeader.id == static_cast(PacketID::SC_SellResult)) { + PKT_SC_SellResult res; + boost::asio::read(socket, boost::asio::buffer(&res, sizeof(res))); + std::cout << ">> 판매 결과: " << res.earnedGold + << " 골드 획득! (총 골드: " << res.totalGold << ")" + << std::endl; + } + + std::this_thread::sleep_for(std::chrono::seconds(1)); + + } catch (std::exception &e) { + std::cerr << "Exception: " << e.what() << "\n"; + } + + return 0; +}