diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9878061 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,182 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# 서버 빌드 및 실행 +cd MMOTestServer +dotnet build -c Debug +dotnet run --project MMOserver/MMOserver.csproj + +# 릴리즈 빌드 +dotnet build -c Release +dotnet publish -c Release + +# Docker +cd MMOTestServer +docker-compose up --build # 9050/udp, 9500/udp 노출 + +# 클라이언트 테스터 +cd ClientTester/EchoClientTester +dotnet run # 대화형 메뉴 +dotnet run -- stress -c 100 -d 60 # 부하 테스트 +dotnet run -- stress -c 50 -d 0 -i 100 -r 1000 -b 10 # 상세 옵션 +``` + +## Tech Stack + +- **C# / .NET 9.0** (C# 13) +- **LiteNetLib** — UDP 네트워킹 +- **protobuf-net** — 바이너리 패킷 직렬화 +- **MySQL** + **Dapper** / **Dapper.Contrib** — 비동기 DB 접근 +- **Serilog** — 로깅 (콘솔 + 파일) + +## Project Purpose + +"One of the plans" 게임의 실시간 MMO 서버. +로비, 채널 관리, 플레이어 동기화 (이동/전투/상태) 담당. + +## Project Structure + +``` +MMOTestServer/ +├── MMOserver/ # 게임 서버 실행 프로젝트 +│ ├── Game/ # GameServer, Player, Channel, ChannelManager +│ ├── Packet/ # PacketHeader(코드 정의), PacketBody(Protobuf 메시지) +│ ├── Api/ # RestApi (JWT 검증 외부 호출) +│ ├── RDB/ # Handler → Service → Repository → MySQL +│ ├── Utils/ # UuidGeneratorManager, Singleton +│ ├── Program.cs # 진입점 +│ ├── config.json # DB 연결 설정 +│ └── Dockerfile # 멀티스테이지 빌드 +├── ServerLib/ # 재사용 네트워킹 DLL +│ ├── Service/ # ServerBase, Session, SessionManager +│ ├── Packet/ # PacketSerializer +│ └── RDB/ # ARepository, DbConnectionFactory +└── compose.yaml # Docker Compose + +ClientTester/ +└── EchoClientTester/ # 테스트 클라이언트 + ├── EchoDummyService/ # Echo RTT 측정 + ├── DummyService/ # 더미 플레이어 이동 시뮬레이션 (10명) + └── StressTest/ # 부하 테스트 (P50/P95/P99, CSV 내보내기) +``` + +## 핵심 아키텍처 + +### 네트워크 코어 (ServerLib) + +**ServerBase** — LiteNetLib `INetEventListener` 구현. 싱글 스레드 이벤트 폴링 (1ms 간격). + +연결 흐름: +``` +UDP Connect → PendingPeer 등록 → 인증 패킷 수신 → Session 생성 → OnSessionConnected +``` + +- **PendingPeers**: `Dictionary` (peer.Id 기반, 미인증) +- **Sessions**: `Dictionary` (hashKey 기반, 인증 완료) +- **TokenHash**: `Dictionary` (JWT 토큰 → hashKey 매핑) +- **재접속**: 같은 hashKey로 재접속 시 기존 peer 교체 (WiFi↔LTE 전환 대응) +- **전송 헬퍼**: `SendTo()`, `Broadcast()`, `BroadcastExcept()` (캐시된 NetDataWriter 사용) + +**Session** — 연결별 상태: Token, HashKey, Peer 참조. +- 속도 제한: 슬라이딩 윈도우 (1초), 기본 60 패킷/초 +- 3회 위반 시 강제 연결 해제 + +### 패킷 프로토콜 + +바이너리 헤더 + Protobuf 페이로드: +``` +[2B: type (ushort LE)] [2B: size (ushort LE)] [NB: protobuf payload] +``` + +**패킷 코드 (PacketHeader.cs):** + +| 코드 | 이름 | 전송 방식 | 용도 | +|------|------|-----------|------| +| 1 | ACC_TOKEN | Reliable | JWT 인증 | +| 1001 | DUMMY_ACC_TOKEN | Reliable | 테스트 인증 (hashKey≤1000) | +| 1000 | ECHO | ReliableUnordered | RTT 측정 | +| 2 | LOAD_GAME | Reliable | 게임 로드 | +| 3 | LOAD_CHANNEL | Reliable | 채널 목록 | +| 4 | INTO_CHANNEL | Reliable | 채널 입장 | +| 5 | UPDATE_CHANNEL_USER | Reliable | 유저 입장/퇴장 브로드캐스트 | +| 6 | EXIT_CHANNEL | Reliable | 채널 퇴장 | +| 7 | TRANSFORM_PLAYER | **Unreliable** | 위치/회전 동기화 (HOL 방지) | +| 8 | ACTION_PLAYER | ReliableOrdered | 공격/스킬/회피 액션 | +| 9 | STATE_PLAYER | ReliableOrdered | HP/MP 상태 | +| 10-12 | TRANSFORM/ACTION/STATE_NPC | 위와 동일 | NPC 동기화 | +| 13 | DAMAGE | Reliable | 데미지 | +| 14-15 | UPDATE_PARTY/USER_PARTY | Reliable | 파티 | + +**Protobuf 메시지 (PacketBody.cs):** +- PlayerInfo: PlayerId, Nickname, Level, Hp, MaxHp, Mp, MaxMp, Position(Vector3), RotY +- TransformPlayerPacket: PlayerId, Position, RotY +- ActionPlayerPacket: PlayerId, PlayerActionType(IDLE/MOVE/ATTACK/SKILL/DODGE/DIE/REVIVE), SkillId, TargetId +- StatePlayerPacket: PlayerId, Hp, MaxHp, Mp, MaxMp + +### GameServer (MMOserver/Game/) + +ServerBase 확장. 패킷 핸들러 딕셔너리로 라우팅: +- `INTO_CHANNEL` — 채널 입장, 기존 유저 목록 응답 + 신규 유저 브로드캐스트 +- `EXIT_CHANNEL` — 채널 퇴장, 나머지 유저에게 알림 +- `TRANSFORM_PLAYER` — 위치 브로드캐스트 (Unreliable) +- `ACTION_PLAYER` — 액션 브로드캐스트 (ReliableOrdered) +- `STATE_PLAYER` — 상태 브로드캐스트 (ReliableOrdered) + +**인증 흐름:** +- `HandleAuth()`: JWT를 `https://a301.api.tolelom.xyz/api/auth/verify`로 검증 (3회 재시도, 5초 타임아웃). 401이면 즉시 실패. +- `HandleAuthDummy()`: 토큰 값을 hashKey로 직접 사용 (hashKey≤1000만 허용). + +### 채널 시스템 + +- **Channel**: `Dictionary` (hashKey → Player). 기본 최대 100명. +- **ChannelManager**: 싱글톤. 기본 1개 채널. `connectUsers` 딕셔너리로 userId→channelId 추적. + +### DB 레이어 + +**Handler → Service → Repository → MySQL** 패턴. + +- **ARepository\**: Dapper.Contrib 기반 제네릭 CRUD. 트랜잭션 지원 (`WithTransactionAsync`). +- **DbConnectionFactory**: `config.json` 또는 환경변수에서 연결 문자열 생성. 커넥션 풀: Min=5, Max=100. +- **Response\**: 표준 응답 봉투 (`{ Success, Data, Error }`). + +새 테이블 추가 시: `Models/` → `Repositories/` (ARepository 상속) → `Services/` → `Handlers/`. ServerLib은 수정 불필요. + +## 스레딩 모델 + +- 네트워크 루프: 싱글 스레드 폴링 (게임 상태에 락 불필요) +- JWT 검증 / DB: async/await (네트워크 루프 블로킹 없음) +- UUID 생성: ReaderWriterLockSlim + +## 설정 + +```json +// config.json +{ + "Database": { + "Host": "localhost", + "Port": "3306", + "Name": "game_db", + "User": "root", + "Password": "...", + "Pooling": { + "MinimumPoolSize": "5", + "MaximumPoolSize": "100", + "ConnectionTimeout": "30", + "ConnectionIdleTimeout": "180" + } + } +} +``` + +서버 기본 포트: 9500 (UDP). Ping 간격: 3000ms. 연결 타임아웃: 60000ms. + +## 알려진 제한사항 + +- 서버 측 위치 검증 미구현 (스피드핵/텔레포트 가능) +- 플레이어 데이터 DB 영속화 미구현 (하드코딩된 기본값 사용) +- NPC/파티 패킷 코드 정의됨 but 핸들러 미구현 +- 수평 확장 미지원 (단일 서버 인스턴스) diff --git a/MMOTestServer/MMOserver/Api/RestApi.cs b/MMOTestServer/MMOserver/Api/RestApi.cs index cd24bf4..99e1f7a 100644 --- a/MMOTestServer/MMOserver/Api/RestApi.cs +++ b/MMOTestServer/MMOserver/Api/RestApi.cs @@ -60,6 +60,38 @@ public class RestApi : Singleton return null; } + // 플레이어 프로필 조회 - 성공 시 PlayerProfileResponse 반환 + public async Task GetPlayerProfileAsync(string username) + { + string url = VERIFY_URL + "/api/internal/player/profile?username=" + Uri.EscapeDataString(username); + for (int attempt = 1; attempt <= MAX_RETRY; attempt++) + { + try + { + HttpResponseMessage response = await httpClient.GetAsync(url); + if (response.StatusCode == HttpStatusCode.NotFound) + { + Log.Warning("[RestApi] 프로필 없음 username={Username}", username); + return null; + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + catch (Exception ex) when (attempt < MAX_RETRY) + { + Log.Warning("[RestApi] 프로필 조회 실패 (시도 {Attempt}/{Max}): {Message}", attempt, MAX_RETRY, ex.Message); + await Task.Delay(RETRY_DELAY); + } + catch (Exception ex) + { + Log.Error("[RestApi] 프로필 조회 최종 실패 ({Max}회 시도): {Message}", MAX_RETRY, ex.Message); + } + } + + return null; + } + private sealed class AuthVerifyResponse { [JsonPropertyName("username")] @@ -69,4 +101,34 @@ public class RestApi : Singleton set; } } + + public sealed class PlayerProfileResponse + { + [JsonPropertyName("nickname")] + public string Nickname { get; set; } = string.Empty; + + [JsonPropertyName("level")] + public int Level { get; set; } + + [JsonPropertyName("experience")] + public int Experience { get; set; } + + [JsonPropertyName("nextExp")] + public int NextExp { get; set; } + + [JsonPropertyName("maxHp")] + public double MaxHp { get; set; } + + [JsonPropertyName("maxMp")] + public double MaxMp { get; set; } + + [JsonPropertyName("attackPower")] + public double AttackPower { get; set; } + + [JsonPropertyName("attackRange")] + public double AttackRange { get; set; } + + [JsonPropertyName("sprintMultiplier")] + public double SprintMultiplier { get; set; } + } } diff --git a/MMOTestServer/MMOserver/Game/GameServer.cs b/MMOTestServer/MMOserver/Game/GameServer.cs index 8aec1e8..de25b12 100644 --- a/MMOTestServer/MMOserver/Game/GameServer.cs +++ b/MMOTestServer/MMOserver/Game/GameServer.cs @@ -98,6 +98,7 @@ public class GameServer : ServerBase { AccTokenPacket accTokenPacket = Serializer.Deserialize(new ReadOnlyMemory(payload)); string token = accTokenPacket.Token; + string? verifiedUsername = null; tokenHash.TryGetValue(token, out int hashKey); if (hashKey <= 1000) { @@ -125,10 +126,15 @@ public class GameServer : ServerBase } Log.Information("[Server] 토큰 검증 성공 Username={Username} PeerId={Id}", username, peer.Id); + verifiedUsername = username; } peer.Tag = new Session(hashKey, peer); ((Session)peer.Tag).Token = token; + if (verifiedUsername != null) + { + ((Session)peer.Tag).Username = verifiedUsername; + } sessions[hashKey] = peer; tokenHash[token] = hashKey; pendingPeers.Remove(peer.Id); @@ -370,6 +376,11 @@ public class GameServer : ServerBase MaxMp = player.MaxMp, Position = new Position { X = player.PosX, Y = player.PosY, Z = player.PosZ }, RotY = player.RotY, + Experience = player.Experience, + NextExp = player.NextExp, + AttackPower = player.AttackPower, + AttackRange = player.AttackRange, + SprintMultiplier = player.SprintMultiplier, }; } @@ -377,7 +388,7 @@ public class GameServer : ServerBase // 패킷 핸들러 // ============================================================ - private void OnIntoChannel(NetPeer peer, int hashKey, byte[] payload) + private async void OnIntoChannel(NetPeer peer, int hashKey, byte[] payload) { IntoChannelPacket packet = Serializer.Deserialize(new ReadOnlyMemory(payload)); @@ -416,7 +427,7 @@ public class GameServer : ServerBase return; } - // TODO: 실제 서비스에서는 DB/세션에서 플레이어 정보 로드 필요 + // API 서버에서 플레이어 프로필 로드 Player newPlayer = new Player { HashKey = hashKey, @@ -424,6 +435,42 @@ public class GameServer : ServerBase Nickname = hashKey.ToString() }; + Session? session = peer.Tag as Session; + string? username = session?.Username; + if (!string.IsNullOrEmpty(username)) + { + try + { + RestApi.PlayerProfileResponse? profile = await RestApi.Instance.GetPlayerProfileAsync(username); + if (profile != null) + { + newPlayer.Nickname = string.IsNullOrEmpty(profile.Nickname) ? username : profile.Nickname; + newPlayer.Level = profile.Level; + newPlayer.MaxHp = (int)profile.MaxHp; + newPlayer.Hp = (int)profile.MaxHp; + newPlayer.MaxMp = (int)profile.MaxMp; + newPlayer.Mp = (int)profile.MaxMp; + newPlayer.Experience = profile.Experience; + newPlayer.NextExp = profile.NextExp; + newPlayer.AttackPower = (float)profile.AttackPower; + newPlayer.AttackRange = (float)profile.AttackRange; + newPlayer.SprintMultiplier = (float)profile.SprintMultiplier; + Log.Information("[GameServer] 프로필 로드 완료 Username={Username} Level={Level} MaxHp={MaxHp}", + username, profile.Level, profile.MaxHp); + } + else + { + newPlayer.Nickname = username; + Log.Warning("[GameServer] 프로필 로드 실패 — 기본값 사용 Username={Username}", username); + } + } + catch (Exception ex) + { + newPlayer.Nickname = username; + Log.Error(ex, "[GameServer] 프로필 로드 예외 Username={Username}", username); + } + } + cm.AddUser(packet.ChannelId, hashKey, newPlayer, peer); Log.Debug("[GameServer] INTO_CHANNEL HashKey={Key} ChannelId={ChannelId}", hashKey, packet.ChannelId); diff --git a/MMOTestServer/MMOserver/Game/Player.cs b/MMOTestServer/MMOserver/Game/Player.cs index 6ad8325..6e1e4bf 100644 --- a/MMOTestServer/MMOserver/Game/Player.cs +++ b/MMOTestServer/MMOserver/Game/Player.cs @@ -50,6 +50,36 @@ public class Player set; } + public int Experience + { + get; + set; + } + + public int NextExp + { + get; + set; + } + + public float AttackPower + { + get; + set; + } + + public float AttackRange + { + get; + set; + } + + public float SprintMultiplier + { + get; + set; + } + // 위치/방향 (클라이언트 패킷과 동일하게 float) public float PosX { diff --git a/MMOTestServer/MMOserver/Packet/PacketBody.cs b/MMOTestServer/MMOserver/Packet/PacketBody.cs index 6903fad..a804474 100644 --- a/MMOTestServer/MMOserver/Packet/PacketBody.cs +++ b/MMOTestServer/MMOserver/Packet/PacketBody.cs @@ -114,6 +114,41 @@ public class PlayerInfo get; set; } + + [ProtoMember(10)] + public int Experience + { + get; + set; + } + + [ProtoMember(11)] + public int NextExp + { + get; + set; + } + + [ProtoMember(12)] + public float AttackPower + { + get; + set; + } + + [ProtoMember(13)] + public float AttackRange + { + get; + set; + } + + [ProtoMember(14)] + public float SprintMultiplier + { + get; + set; + } } // ============================================================ diff --git a/MMOTestServer/ServerLib/Service/Session.cs b/MMOTestServer/ServerLib/Service/Session.cs index c847289..0d5afd5 100644 --- a/MMOTestServer/ServerLib/Service/Session.cs +++ b/MMOTestServer/ServerLib/Service/Session.cs @@ -10,6 +10,12 @@ public class Session set; } + public string? Username + { + get; + set; + } + public int HashKey { get;