From f6b378cad7c741d9e5b42f1cd4c682086a7bbc80 Mon Sep 17 00:00:00 2001 From: qornwh1 Date: Mon, 16 Mar 2026 17:55:08 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EB=B3=B4=EC=8A=A4=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=9E=85=EC=9E=A5=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MMOTestServer/MMOserver/Api/RestApi.cs | 94 +++++++++++- .../MMOserver/Game/Channel/Channel.cs | 46 +++++- MMOTestServer/MMOserver/Game/GameServer.cs | 134 +++++++++++++++++- MMOTestServer/MMOserver/Game/Player.cs | 7 + MMOTestServer/MMOserver/Packet/PacketBody.cs | 24 ++++ .../MMOserver/Packet/PacketHeader.cs | 3 + MMOTestServer/ServerLib/Service/Session.cs | 18 ++- 7 files changed, 312 insertions(+), 14 deletions(-) diff --git a/MMOTestServer/MMOserver/Api/RestApi.cs b/MMOTestServer/MMOserver/Api/RestApi.cs index 998b425..254f464 100644 --- a/MMOTestServer/MMOserver/Api/RestApi.cs +++ b/MMOTestServer/MMOserver/Api/RestApi.cs @@ -35,7 +35,7 @@ public class RestApi : Singleton if (response.StatusCode == HttpStatusCode.Unauthorized) { Log.Warning("[RestApi] 토큰 인증 실패 (401)"); - return null; + return ""; } response.EnsureSuccessStatusCode(); @@ -56,7 +56,7 @@ public class RestApi : Singleton } } - return null; + return ""; } private sealed class AuthVerifyResponse @@ -68,4 +68,94 @@ public class RestApi : Singleton set; } } + + // 레이드 채널 접속 여부 체크 + // 성공 시 sessionName 반환, 실패/거절 시 null 반환 + public async Task BossRaidAccesssAsync(List userNames, int bossId) + { + string url = AppConfig.RestApi.BaseUrl + "/api/internal/bossraid/entry"; + + for (int attempt = 1; attempt <= MAX_RETRY; attempt++) + { + try + { + HttpResponseMessage response = await httpClient.PostAsJsonAsync(url, new { usernames = userNames, bossId }); + + // 401: API 키 인증 실패 + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + Log.Warning("[RestApi] 보스 레이드 접속 인증 실패 (401)"); + return false; + } + + // 400: 입장 조건 미충족 (레벨 부족, 이미 진행중 등) + if (response.StatusCode == HttpStatusCode.BadRequest) + { + Log.Warning("[RestApi] 보스 레이드 입장 거절 (400) BossId={BossId}", bossId); + return false; + } + + response.EnsureSuccessStatusCode(); + + BossRaidAccessResponse? result = await response.Content.ReadFromJsonAsync(); + return result?.BossId > 0 ? true : false; + } + 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 false; + } + + private sealed class BossRaidAccessResponse + { + [JsonPropertyName("roomId")] + public int RoomId + { + get; + set; + } + + [JsonPropertyName("sessionName")] + public string? SessionName + { + get; + set; + } + + [JsonPropertyName("bossId")] + public int BossId + { + get; + set; + } + + [JsonPropertyName("players")] + public List Players + { + get; + set; + } = new(); + + [JsonPropertyName("status")] + public string? Status + { + get; + set; + } + + [JsonPropertyName("tokens")] + public Dictionary Tokens + { + get; + set; + } = new(); + } } diff --git a/MMOTestServer/MMOserver/Game/Channel/Channel.cs b/MMOTestServer/MMOserver/Game/Channel/Channel.cs index b4a867a..98ee1ff 100644 --- a/MMOTestServer/MMOserver/Game/Channel/Channel.cs +++ b/MMOTestServer/MMOserver/Game/Channel/Channel.cs @@ -16,6 +16,12 @@ public class Channel // 채널 맵 관리 private readonly Dictionary maps = new(); + // 진행중 채널 맵 관리 + private readonly Dictionary useInstanceMaps = new(); + + // 동적 레이드 맵 할당용 ID 카운터 (사전 생성 10개 이후부터 시작) + private int nextDynamicRaidMapId = 1011; + // 파티 private readonly PartyManager partyManager = new(); @@ -29,7 +35,7 @@ public class Channel maps.Add(1, new Robby(1)); // 인던 - int defaultValue = 10; + int defaultValue = 1000; for (int i = 1; i <= 10; i++) { maps.Add(i + defaultValue, new BossInstance(i + defaultValue)); @@ -152,9 +158,47 @@ public class Channel return map; } + // 사용 가능한 레이드 맵 ID 반환 + // 기존 맵(1001~) 중 미사용 탐색 → 없으면 동적 생성 후 반환 + public int GetOrCreateAvailableRaidMap() + { + // 기존 맵 중 미사용 탐색 + foreach (int mapId in maps.Keys) + { + if (mapId >= 1001 && !useInstanceMaps.ContainsKey(mapId)) + { + return mapId; + } + } + + // 모두 사용 중 → 동적 생성 + int newMapId = nextDynamicRaidMapId++; + BossInstance newMap = new(newMapId); + maps.Add(newMapId, newMap); + + return newMapId; + } + + // 레이드 맵 사용 시작 (진행중 목록에 등록) + public void AddInstanceMap(int mapId) + { + if (maps.TryGetValue(mapId, out AMap? map)) + { + useInstanceMaps[mapId] = map; + } + } + + // 레이드 맵 사용 종료 (진행중 목록에서 제거) + public void RemoveInstanceMap(int mapId) + { + useInstanceMaps.Remove(mapId); + } + // 파티매니저 가져옴 public PartyManager GetPartyManager() { return partyManager; } + + // TODO : 채널 가져오기 } diff --git a/MMOTestServer/MMOserver/Game/GameServer.cs b/MMOTestServer/MMOserver/Game/GameServer.cs index 6acf37d..e02fd0f 100644 --- a/MMOTestServer/MMOserver/Game/GameServer.cs +++ b/MMOTestServer/MMOserver/Game/GameServer.cs @@ -30,6 +30,7 @@ public class GameServer : ServerBase [(ushort)PacketCode.STATE_PLAYER] = OnStatePlayer, [(ushort)PacketCode.CHANGE_MAP] = OnChangeMap, [(ushort)PacketCode.PARTY_CHANGE_MAP] = OnPartyChangeMap, + [(ushort)PacketCode.INTO_BOSS_RAID] = OnIntoBossRaid, [(ushort)PacketCode.REQUEST_PARTY] = OnRequestParty, [(ushort)PacketCode.CHAT] = OnChat }; @@ -102,6 +103,7 @@ public class GameServer : ServerBase { AccTokenPacket accTokenPacket = Serializer.Deserialize(new ReadOnlyMemory(payload)); string token = accTokenPacket.Token; + string username = ""; tokenHash.TryGetValue(token, out int hashKey); if (hashKey <= 1000) { @@ -119,7 +121,7 @@ public class GameServer : ServerBase else { // 신규 연결: 웹서버에 JWT 검증 요청 - string? username = await RestApi.Instance.VerifyTokenAsync(token); + username = await RestApi.Instance.VerifyTokenAsync(token); if (username == null) { Log.Warning("[Server] 토큰 검증 실패 - 연결 거부 PeerId={Id}", peer.Id); @@ -133,6 +135,11 @@ public class GameServer : ServerBase peer.Tag = new Session(hashKey, peer); ((Session)peer.Tag).Token = token; + if (username.Length > 0) + { + ((Session)peer.Tag).UserName = username; + } + sessions[hashKey] = peer; tokenHash[token] = hashKey; pendingPeers.Remove(peer.Id); @@ -451,7 +458,7 @@ public class GameServer : ServerBase { HashKey = hashKey, PlayerId = hashKey, - Nickname = hashKey.ToString() + Nickname = ((Session)peer.Tag).UserName }; // 채널에 추가 @@ -481,12 +488,16 @@ public class GameServer : ServerBase { foreach (var (userId, p) in initMap.GetUsers()) { - if (userId == hashKey) continue; + if (userId == hashKey) + { + continue; + } + response.Players.Add(ToPlayerInfo(p)); } } - SendTo(peer, PacketSerializer.Serialize((ushort)PacketCode.CHANGE_MAP, response)); + SendTo(peer, PacketSerializer.Serialize((ushort)PacketCode.CHANGE_MAP, response)); } private void OnIntoChannelParty(NetPeer peer, int hashKey, byte[] payload) @@ -1026,6 +1037,121 @@ public class GameServer : ServerBase Log.Debug("[GameServer] PARTY_CHANGE_MAP HashKey={Key} PartyId={PartyId} MapId={MapId}", hashKey, packet.PartyId, packet.MapId); } + private async void OnIntoBossRaid(NetPeer peer, int hashKey, byte[] payload) + { + IntoBossRaidPacket packet = Serializer.Deserialize(new ReadOnlyMemory(payload)); + + ChannelManager cm = ChannelManager.Instance; + int channelId = cm.HasUser(hashKey); + if (channelId < 0) + { + return; + } + + Channel.Channel channel = cm.GetChannel(channelId); + + // 파티 조회 + PartyInfo? party = channel.GetPartyManager().GetPartyByPlayer(hashKey); + if (party == null) + { + Log.Warning("[GameServer] INTO_BOSS_RAID 파티 없음 HashKey={Key}", hashKey); + SendTo(peer, PacketSerializer.Serialize((ushort)PacketCode.INTO_BOSS_RAID, + new IntoBossRaidPacket { RaidId = packet.RaidId, IsSuccess = false })); + return; + } + + // 파티장만 요청 가능 + if (party.LeaderId != hashKey) + { + Log.Warning("[GameServer] INTO_BOSS_RAID 파티장 아님 HashKey={Key} LeaderId={LeaderId}", hashKey, party.LeaderId); + SendTo(peer, PacketSerializer.Serialize((ushort)PacketCode.INTO_BOSS_RAID, + new IntoBossRaidPacket { RaidId = packet.RaidId, IsSuccess = false })); + return; + } + + // API로 접속 체크 + List userNames = new List(); + foreach (int memberId in party.PartyMemberIds) + { + userNames.Add(channel.GetPlayer(memberId).Nickname); + } + + bool? result = await RestApi.Instance.BossRaidAccesssAsync(userNames, 1); + + // 입장 실패 + if (result.Value == false) + { + SendTo(peer, + PacketSerializer.Serialize((ushort)PacketCode.INTO_BOSS_RAID, + new IntoBossRaidPacket { RaidId = -1, IsSuccess = false })); + + Log.Debug("[GameServer] INTO_BOSS_RAID HashKey={Key} PartyId={PartyId} AssignedRaidMapId={RaidId} Failed", hashKey, + party.PartyId, -1); + } + + // 레이드 맵 할당 (미사용 맵 탐색 → 없으면 동적 생성) + int assignedRaidMapId = channel.GetOrCreateAvailableRaidMap(); + + // 진행중 맵으로 등록 + channel.AddInstanceMap(assignedRaidMapId); + + // 파티원 전체 레이드 맵으로 이동 + 각자에게 알림 + foreach (int memberId in party.PartyMemberIds) + { + Player? memberPlayer = channel.GetPlayer(memberId); + if (memberPlayer == null) + { + continue; + } + + // 이전 맵 캐싱 (레이드 종료 후 복귀용) + memberPlayer.PreviousMapId = memberPlayer.CurrentMapId; + + int oldMapId = memberPlayer.CurrentMapId; + channel.ChangeMap(memberId, memberPlayer, assignedRaidMapId); + + if (!sessions.TryGetValue(memberId, out NetPeer? memberPeer)) + { + continue; + } + + PlayerInfo memberInfo = ToPlayerInfo(memberPlayer); + + // 기존 맵 유저들에게 퇴장 알림 + ChangeMapPacket exitNotify = new() { MapId = oldMapId, IsAdd = false, Player = memberInfo }; + BroadcastToMap(channelId, oldMapId, PacketSerializer.Serialize((ushort)PacketCode.CHANGE_MAP, exitNotify)); + + // 새 맵 유저들에게 입장 알림 (본인 제외) + ChangeMapPacket enterNotify = new() { MapId = assignedRaidMapId, IsAdd = true, Player = memberInfo }; + BroadcastToMap(channelId, assignedRaidMapId, PacketSerializer.Serialize((ushort)PacketCode.CHANGE_MAP, enterNotify), + memberPeer); + + // 본인에게 새 맵 플레이어 목록 전달 + ChangeMapPacket response = new() { MapId = assignedRaidMapId }; + AMap? raidMap = channel.GetMap(assignedRaidMapId); + if (raidMap != null) + { + foreach ((int uid, Player p) in raidMap.GetUsers()) + { + if (uid != memberId) + { + response.Players.Add(ToPlayerInfo(p)); + } + } + } + + SendTo(memberPeer, PacketSerializer.Serialize((ushort)PacketCode.CHANGE_MAP, response)); + } + + // 파티장에게 입장 성공 응답 (할당된 실제 레이드 맵 ID 전달) + SendTo(peer, + PacketSerializer.Serialize((ushort)PacketCode.INTO_BOSS_RAID, + new IntoBossRaidPacket { RaidId = assignedRaidMapId, IsSuccess = true })); + + Log.Debug("[GameServer] INTO_BOSS_RAID HashKey={Key} PartyId={PartyId} AssignedRaidMapId={RaidId}", hashKey, party.PartyId, + assignedRaidMapId); + } + // 채널 퇴장/연결 해제 시 파티 자동 탈퇴 처리 private void HandlePartyLeaveOnExit(int channelId, int hashKey) { diff --git a/MMOTestServer/MMOserver/Game/Player.cs b/MMOTestServer/MMOserver/Game/Player.cs index 7008477..6f82814 100644 --- a/MMOTestServer/MMOserver/Game/Player.cs +++ b/MMOTestServer/MMOserver/Game/Player.cs @@ -81,4 +81,11 @@ public class Player get; set; } + + // 레이드 입장 전 이전 맵 ID (레이드 종료 후 복귀용, 서버 캐싱) + public int PreviousMapId + { + get; + set; + } } diff --git a/MMOTestServer/MMOserver/Packet/PacketBody.cs b/MMOTestServer/MMOserver/Packet/PacketBody.cs index 9fbdde6..bfc45c4 100644 --- a/MMOTestServer/MMOserver/Packet/PacketBody.cs +++ b/MMOTestServer/MMOserver/Packet/PacketBody.cs @@ -747,6 +747,30 @@ public class ChangeMapPacket } } +// INTO_BOSS_RAID +// 클라->서버: RaidId +// 서버->클라: RaidId + IsSuccess (파티장에게 결과 전달) +// 성공 시 파티원 전체에게 CHANGE_MAP 추가 전송 +[ProtoContract] +public class IntoBossRaidPacket +{ + // 입장할 보스 레이드 맵 Id + [ProtoMember(1)] + public int RaidId + { + get; + set; + } + + // 입장 성공 여부 (서버 -> 클라) + [ProtoMember(2)] + public bool IsSuccess + { + get; + set; + } +} + // PARTY_CHANGE_MAP (클라 -> 서버 전용) [ProtoContract] public class PartyChangeMapPacket diff --git a/MMOTestServer/MMOserver/Packet/PacketHeader.cs b/MMOTestServer/MMOserver/Packet/PacketHeader.cs index c452f52..44d012e 100644 --- a/MMOTestServer/MMOserver/Packet/PacketHeader.cs +++ b/MMOTestServer/MMOserver/Packet/PacketHeader.cs @@ -36,6 +36,9 @@ public enum PacketCode : ushort // 단체로 맵 이동 PARTY_CHANGE_MAP, + // 파티장이 보스 레이드(인스턴스 던전) 입장 신청 (클라 -> 서버) + INTO_BOSS_RAID, + // 플레이어 위치, 방향 (서버 -> 클라 \ 클라 -> 서버) TRANSFORM_PLAYER, diff --git a/MMOTestServer/ServerLib/Service/Session.cs b/MMOTestServer/ServerLib/Service/Session.cs index c847289..84be6d7 100644 --- a/MMOTestServer/ServerLib/Service/Session.cs +++ b/MMOTestServer/ServerLib/Service/Session.cs @@ -16,25 +16,29 @@ public class Session init; } + public string UserName + { + get; + set; + } + public NetPeer Peer { get; set; } - // ─── 패킷 레이트 리미팅 ─────────────────────────── + // 패킷 레이트 리미팅 private int packetCount; private long windowStartTicks; - /// 초당 허용 패킷 수 + // 초당 허용 패킷 수 public int MaxPacketsPerSecond { get; set; } - /// 연속 초과 횟수 + // 연속 초과 횟수 public int RateLimitViolations { get; private set; } - /// - /// 패킷 수신 시 호출. 초당 제한 초과 시 true 반환. - /// + // 패킷 수신 시 호출. 초당 제한 초과 시 true 반환. public bool CheckRateLimit() { long now = Environment.TickCount64; @@ -57,7 +61,7 @@ public class Session return false; } - /// 위반 카운트 초기화 + // 위반 카운트 초기화 public void ResetViolations() { RateLimitViolations = 0;