From c52668be1e19e030fcac23536bd7bdbf35ef201c Mon Sep 17 00:00:00 2001 From: bumpsoo Date: Sun, 8 Feb 2026 15:56:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=8B=9C=20=EC=9C=A0=EC=A0=80=EC=9D=98=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EA=B0=92=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/main.py | 3 +++ proto/Protocol.proto | 2 ++ server/PacketHandler.cpp | 2 ++ 3 files changed, 7 insertions(+) diff --git a/client/main.py b/client/main.py index 10df3b2..6310211 100644 --- a/client/main.py +++ b/client/main.py @@ -145,7 +145,10 @@ class SwordGameClient(ctk.CTk): res = Protocol.SC_LoginResult() res.ParseFromString(payload) if res.success: + self.current_gold = res.gold + self.current_level = res.sword_level self.after(0, self.setup_game_ui) + self.after(0, self.update_stats) else: print("Login Failed") diff --git a/proto/Protocol.proto b/proto/Protocol.proto index 08eaf1e..a5ad3f2 100644 --- a/proto/Protocol.proto +++ b/proto/Protocol.proto @@ -8,6 +8,8 @@ message CS_Login { message SC_LoginResult { bool success = 1; + uint64 gold = 2; + uint32 sword_level = 3; } message SC_UpgradeResult { diff --git a/server/PacketHandler.cpp b/server/PacketHandler.cpp index 291d020..39a20fc 100644 --- a/server/PacketHandler.cpp +++ b/server/PacketHandler.cpp @@ -46,6 +46,8 @@ PacketHandler::HandlePacket(std::shared_ptr session, ", Level: ", session->GetSwordLevel(), ")"); loginResult.set_success(true); + loginResult.set_gold(session->GetGold()); + loginResult.set_sword_level(session->GetSwordLevel()); session->SendPacket(PacketID::SC_LoginResult, loginResult); } break; From 30debc44f58667f37b7bc76e62a937a7ac122db1 Mon Sep 17 00:00:00 2001 From: bumpsoo Date: Sun, 8 Feb 2026 16:20:59 +0900 Subject: [PATCH 2/4] doc: README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 95ffc6f..df633bf 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,5 @@ cmake --build build [README.md](https://git.bumpsoo.dev/bumpsoo/sword_game/src/branch/main/client/README.md) ## TODO: 개선 사항 -- 현재 서버 메모리에만 유지되는 검 강화 상태를 DB에 저장하여, 재접속 시에도 이전 상태가 그대로 복구되도록 구현. - DB Connection Pool 도입 - 랭킹 view 기능 추가 \ No newline at end of file From 71e09915b72154b21518b0362eb037c488998aac Mon Sep 17 00:00:00 2001 From: bumpsoo Date: Sun, 8 Feb 2026 17:08:37 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=B5=9C=EB=8C=80=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20=EB=8B=A8=EA=B3=84,=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EA=B3=A8=EB=93=9C=20=EA=B8=B0=EB=B0=98=20=EB=9E=AD=ED=81=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/main.py | 68 +++++++++++++++++++++++++++++++++- include/DatabaseManager.h | 14 +++++++ proto/Protocol.proto | 19 ++++++++-- schema.sql | 5 ++- server/DatabaseManager.cpp | 75 ++++++++++++++++++++++++++++++++++++-- server/PacketHandler.cpp | 25 +++++++++++++ 6 files changed, 197 insertions(+), 9 deletions(-) diff --git a/client/main.py b/client/main.py index 6310211..a28c51d 100644 --- a/client/main.py +++ b/client/main.py @@ -17,6 +17,10 @@ class PacketID(IntEnum): CS_RankingRequest = 40 SC_RankingList = 41 +class RankingType(IntEnum): + LEVEL = 0 + GOLD = 1 + class SwordGameClient(ctk.CTk): def __init__(self): super().__init__() @@ -57,6 +61,7 @@ class SwordGameClient(ctk.CTk): # 메인 레이아웃 self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(1, weight=2) + self.grid_columnconfigure(2, weight=1) self.grid_rowconfigure(0, weight=1) # 왼쪽: 유저 정보 창 @@ -78,6 +83,9 @@ class SwordGameClient(ctk.CTk): self.sell_btn = ctk.CTkButton(self.info_frame, text="판매 하기", fg_color="#dc3545", hover_color="#c82333", command=self.send_sell) self.sell_btn.pack(pady=10, fill="x", padx=20) + self.ranking_btn = ctk.CTkButton(self.info_frame, text="랭킹 갱신", fg_color="#17a2b8", hover_color="#138496", command=self.request_rankings) + self.ranking_btn.pack(pady=10, fill="x", padx=20) + # 오른쪽: 채팅 창 self.chat_frame = ctk.CTkFrame(self) self.chat_frame.grid(row=0, column=1, padx=20, pady=20, sticky="nsew") @@ -89,7 +97,24 @@ class SwordGameClient(ctk.CTk): self.chat_entry.pack(fill="x", padx=10, pady=(0, 10)) self.chat_entry.bind("", lambda e: self.send_chat()) - # TODO: 채팅창 오른 쪽에 랭킹? + # 오른쪽: 랭킹 창 + self.ranking_frame = ctk.CTkFrame(self) + self.ranking_frame.grid(row=0, column=2, padx=20, pady=20, sticky="nsew") + + self.rank_title = ctk.CTkLabel(self.ranking_frame, text="실시간 랭킹", font=("Arial", 18, "bold")) + self.rank_title.pack(pady=10) + + self.rank_tab = ctk.CTkSegmentedButton(self.ranking_frame, values=["강화 단계", "자산"], command=self.on_rank_tab_changed) + self.rank_tab.set("강화 단계") + self.rank_tab.pack(pady=5, padx=10, fill="x") + + self.rank_list_box = ctk.CTkTextbox(self.ranking_frame, state="disabled", font=("Courier New", 12)) + self.rank_list_box.pack(expand=True, fill="both", padx=10, pady=10) + + self.my_rank_label = ctk.CTkLabel(self.ranking_frame, text="내 순위: -", font=("Arial", 14, "bold"), text_color="#FFD700") + self.my_rank_label.pack(pady=10) + + self.request_rankings() def connect_to_server(self): self.nickname = self.nickname_entry.get() @@ -171,6 +196,11 @@ class SwordGameClient(ctk.CTk): msg = payload.decode('utf-8') self.after(0, lambda: self.log_chat(msg)) + elif pkt_id == PacketID.SC_RankingList: + res = Protocol.SC_RankingList() + res.ParseFromString(payload) + self.after(0, lambda: self.update_ranking_ui(res)) + def update_stats(self): self.level_label.configure(text=f"검 레벨: {self.current_level}") self.gold_label.configure(text=f"골드: {self.current_gold:,}") @@ -194,6 +224,42 @@ class SwordGameClient(ctk.CTk): self.chat_box.see("end") self.chat_box.configure(state="disabled") + def request_rankings(self): + tab = self.rank_tab.get() + rtype = RankingType.LEVEL if tab == "강화 단계" else RankingType.GOLD + pkt = Protocol.CS_RankingRequest() + pkt.type = rtype + self.send_packet(PacketID.CS_RankingRequest, pkt) + + def on_rank_tab_changed(self, value): + self.request_rankings() + + def update_ranking_ui(self, res): + self.rank_list_box.configure(state="normal") + self.rank_list_box.delete("1.0", "end") + + header = f"{'순위':<4} {'닉네임':<12} {'값':<10}\n" + self.rank_list_box.insert("end", header) + self.rank_list_box.insert("end", "-" * 30 + "\n") + + for entry in res.entries: + val_str = f"{entry.value:,}" + if res.type == RankingType.LEVEL: + val_str = f"{entry.value}단계" + + line = f"{entry.rank:<4} {entry.nickname:<12} {val_str:<10}\n" + self.rank_list_box.insert("end", line) + + self.rank_list_box.configure(state="disabled") + + if res.my_rank != -1: + my_val_str = f"{res.my_value:,}" + if res.type == RankingType.LEVEL: + my_val_str = f"{res.my_value}단계" + self.my_rank_label.configure(text=f"내 순위: {res.my_rank}위 ({my_val_str})") + else: + self.my_rank_label.configure(text="내 순위: 기록 없음") + if __name__ == "__main__": app = SwordGameClient() app.mainloop() diff --git a/include/DatabaseManager.h b/include/DatabaseManager.h index 0e84028..2d61505 100644 --- a/include/DatabaseManager.h +++ b/include/DatabaseManager.h @@ -19,11 +19,25 @@ public: std::string nickname; uint64_t gold; uint32_t swordLevel; + uint32_t maxSwordLevel; + }; + + struct RankingResult { + struct Entry { + uint32_t rank; + std::string nickname; + uint64_t value; + }; + std::vector topEntries; + int32_t myRank; + uint64_t myValue; }; boost::asio::awaitable LoadUser(std::string nickname); boost::asio::awaitable SaveUser(std::string nickname, uint64_t gold, uint32_t swordLevel); + boost::asio::awaitable GetRanking(int type, + std::string nickname); private: DatabaseManager() = default; diff --git a/proto/Protocol.proto b/proto/Protocol.proto index a5ad3f2..17b37c0 100644 --- a/proto/Protocol.proto +++ b/proto/Protocol.proto @@ -23,11 +23,24 @@ message SC_SellResult { uint64 total_gold = 2; } +enum RankingType { + RANKING_TYPE_LEVEL = 0; + RANKING_TYPE_GOLD = 1; +} + +message CS_RankingRequest { + RankingType type = 1; +} + message RankingEntry { - string nickname = 1; - uint32 level = 2; + uint32 rank = 1; + string nickname = 2; + uint64 value = 3; } message SC_RankingList { - repeated RankingEntry entries = 1; + RankingType type = 1; + repeated RankingEntry entries = 2; + int32 my_rank = 3; + uint64 my_value = 4; } diff --git a/schema.sql b/schema.sql index 1f3329e..f7bb93f 100644 --- a/schema.sql +++ b/schema.sql @@ -6,5 +6,8 @@ CREATE TABLE IF NOT EXISTS users ( 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 + max_sword_level INT DEFAULT 0, + last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (gold), + INDEX (max_sword_level) ); diff --git a/server/DatabaseManager.cpp b/server/DatabaseManager.cpp index 4374a32..174b4ef 100644 --- a/server/DatabaseManager.cpp +++ b/server/DatabaseManager.cpp @@ -87,7 +87,8 @@ DatabaseManager::LoadUser(std::string nickname) { boost::mysql::results result; boost::mysql::statement stmt = co_await conn_->async_prepare_statement( - "SELECT username, gold, sword_level FROM users WHERE username = ?", + "SELECT username, gold, sword_level, max_sword_level FROM users WHERE " + "username = ?", boost::asio::use_awaitable); co_await conn_->async_execute(stmt.bind(nickname), result, boost::asio::use_awaitable); @@ -109,6 +110,7 @@ DatabaseManager::LoadUser(std::string nickname) { data.nickname = row.at(0).as_string(); data.gold = row.at(1).as_uint64(); data.swordLevel = (uint32_t)row.at(2).as_int64(); + data.maxSwordLevel = (uint32_t)row.at(3).as_int64(); Unlock(); co_return data; } @@ -132,11 +134,12 @@ boost::asio::awaitable DatabaseManager::SaveUser(std::string nickname, boost::mysql::results result; boost::mysql::statement stmt = co_await conn_->async_prepare_statement( - "UPDATE users SET gold = ?, sword_level = ? WHERE username = ?", + "UPDATE users SET gold = ?, sword_level = ?, max_sword_level = " + "GREATEST(max_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); + stmt.bind(gold, (int64_t)swordLevel, (int64_t)swordLevel, nickname), + result, boost::asio::use_awaitable); } catch (const std::exception &e) { Logger::Log("SaveUser 에러: ", e.what()); } @@ -144,3 +147,67 @@ boost::asio::awaitable DatabaseManager::SaveUser(std::string nickname, Unlock(); co_return; } + +boost::asio::awaitable +DatabaseManager::GetRanking(int type, std::string nickname) { + co_await Lock(); + + RankingResult res; + res.myRank = -1; + res.myValue = 0; + + try { + if (!conn_) { + Unlock(); + co_return res; + } + + std::string columnName = (type == 0) ? "max_sword_level" : "gold"; + + boost::mysql::results topResult; + std::string topQuery = "SELECT username, " + columnName + + " FROM users ORDER BY " + columnName + + " DESC LIMIT 10"; + co_await conn_->async_execute(topQuery, topResult, + boost::asio::use_awaitable); + + uint32_t rank = 1; + for (auto row : topResult.rows()) { + RankingResult::Entry entry; + entry.rank = rank++; + entry.nickname = row.at(0).as_string(); + // DB 타입이 달라 분기처리.. + if (type == 0) { + entry.value = (uint64_t)row.at(1).as_int64(); + } else { + entry.value = row.at(1).as_uint64(); + } + res.topEntries.push_back(entry); + } + + boost::mysql::results myResult; + std::string myQuery = + "SELECT " + columnName + ", (SELECT COUNT(*) + 1 FROM users WHERE " + + columnName + " > u." + columnName + ") FROM users u WHERE username = ?"; + + boost::mysql::statement stmt = co_await conn_->async_prepare_statement( + myQuery, boost::asio::use_awaitable); + co_await conn_->async_execute(stmt.bind(nickname), myResult, + boost::asio::use_awaitable); + + if (!myResult.rows().empty()) { + auto row = myResult.rows().at(0); + if (type == 0) { + res.myValue = (uint64_t)row.at(0).as_int64(); + } else { + res.myValue = row.at(0).as_uint64(); + } + res.myRank = (int32_t)row.at(1).as_int64(); + } + } catch (const std::exception &e) { + Logger::Log("GetRanking 에러: ", e.what()); + } + + Unlock(); + co_return res; +} diff --git a/server/PacketHandler.cpp b/server/PacketHandler.cpp index 39a20fc..dd2456a 100644 --- a/server/PacketHandler.cpp +++ b/server/PacketHandler.cpp @@ -139,6 +139,31 @@ PacketHandler::HandlePacket(std::shared_ptr session, SessionManager::GetInstance().Broadcast(packet.header, packet.payload); } break; + case PacketID::CS_RankingRequest: { + Protocol::CS_RankingRequest pkt; + if (!pkt.ParseFromArray(packet.payload.data(), packet.payload.size())) { + Logger::Log("랭킹 요청 패킷 파싱 실패"); + co_return; + } + + auto rankResult = co_await DatabaseManager::GetInstance().GetRanking( + (int)pkt.type(), session->GetNickname()); + + Protocol::SC_RankingList res; + res.set_type(pkt.type()); + res.set_my_rank(rankResult.myRank); + res.set_my_value(rankResult.myValue); + + for (const auto &entry : rankResult.topEntries) { + auto *newEntry = res.add_entries(); + newEntry->set_rank(entry.rank); + newEntry->set_nickname(entry.nickname); + newEntry->set_value(entry.value); + } + + session->SendPacket(PacketID::SC_RankingList, res); + } break; + default: Logger::Log("알 수 없는 패킷 ID: ", packet.header.id); break; From 31b4763bc3b4c36c0c976148248b2f169b87c4c9 Mon Sep 17 00:00:00 2001 From: bumpsoo Date: Sun, 8 Feb 2026 17:11:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?doc:=20README=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index df633bf..978033e 100644 --- a/README.md +++ b/README.md @@ -49,5 +49,4 @@ cmake --build build [README.md](https://git.bumpsoo.dev/bumpsoo/sword_game/src/branch/main/client/README.md) ## TODO: 개선 사항 -- DB Connection Pool 도입 -- 랭킹 view 기능 추가 \ No newline at end of file +- DB Connection Pool 도입 \ No newline at end of file