diff --git a/ClientTester/EchoClientTester/Packet/PacketBody.cs b/ClientTester/EchoClientTester/Packet/PacketBody.cs index b58cb7d..d47f2a2 100644 --- a/ClientTester/EchoClientTester/Packet/PacketBody.cs +++ b/ClientTester/EchoClientTester/Packet/PacketBody.cs @@ -663,7 +663,9 @@ public enum PartyUpdateType DELETE, JOIN, LEAVE, - UPDATE + UPDATE, + INVITE, + KICK } // REQUEST_PARTY (클라 -> 서버) - CREATE: PartyName 사용 / JOIN·LEAVE·DELETE: PartyId 사용 diff --git a/MMOTestServer/MMOserver/Api/RestApi.cs b/MMOTestServer/MMOserver/Api/RestApi.cs index 91d26cc..f50c94f 100644 --- a/MMOTestServer/MMOserver/Api/RestApi.cs +++ b/MMOTestServer/MMOserver/Api/RestApi.cs @@ -129,6 +129,18 @@ public class RestApi : Singleton [JsonPropertyName("sprintMultiplier")] public double SprintMultiplier { get; set; } + + [JsonPropertyName("lastPosX")] + public double LastPosX { get; set; } + + [JsonPropertyName("lastPosY")] + public double LastPosY { get; set; } + + [JsonPropertyName("lastPosZ")] + public double LastPosZ { get; set; } + + [JsonPropertyName("lastRotY")] + public double LastRotY { get; set; } } // 레이드 채널 접속 여부 체크 @@ -192,6 +204,39 @@ public class RestApi : Singleton return null; } + // 게임 데이터 저장 (채널 퇴장 시 위치/플레이타임 저장) + public async Task SaveGameDataAsync(string username, float? posX, float? posY, float? posZ, float? rotY, long? playTimeDelta) + { + string url = AppConfig.RestApi.BaseUrl + "/api/internal/player/save?username=" + Uri.EscapeDataString(username); + + var body = new Dictionary(); + if (posX.HasValue) body["lastPosX"] = posX.Value; + if (posY.HasValue) body["lastPosY"] = posY.Value; + if (posZ.HasValue) body["lastPosZ"] = posZ.Value; + if (rotY.HasValue) body["lastRotY"] = rotY.Value; + if (playTimeDelta.HasValue) body["playTimeDelta"] = playTimeDelta.Value; + + for (int attempt = 1; attempt <= MAX_RETRY; attempt++) + { + try + { + HttpResponseMessage response = await httpClient.PostAsJsonAsync(url, body); + response.EnsureSuccessStatusCode(); + return true; + } + 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")] diff --git a/MMOTestServer/MMOserver/Game/Channel/Channel.cs b/MMOTestServer/MMOserver/Game/Channel/Channel.cs index 386a636..0b0c2cd 100644 --- a/MMOTestServer/MMOserver/Game/Channel/Channel.cs +++ b/MMOTestServer/MMOserver/Game/Channel/Channel.cs @@ -137,6 +137,13 @@ public class Channel return player; } + // 특정 유저의 NetPeer 반환 + public NetPeer? GetPeer(int userId) + { + connectPeers.TryGetValue(userId, out NetPeer? peer); + return peer; + } + public int HasUser(int userId) { if (connectUsers.ContainsKey(userId)) diff --git a/MMOTestServer/MMOserver/Game/GameServer.cs b/MMOTestServer/MMOserver/Game/GameServer.cs index 87df97f..5fa6e92 100644 --- a/MMOTestServer/MMOserver/Game/GameServer.cs +++ b/MMOTestServer/MMOserver/Game/GameServer.cs @@ -207,6 +207,21 @@ public class GameServer : ServerBase int channelId = cm.HasUser(hashKey); Player? player = channelId >= 0 ? cm.GetChannel(channelId).GetPlayer(hashKey) : null; + // 퇴장 시 위치/플레이타임 DB 저장 (fire-and-forget) + if (player != null && peer.Tag is Session session && session.Username != null) + { + long playTimeDelta = 0; + if (session.ChannelJoinedAt != default) + { + playTimeDelta = (long)(DateTime.UtcNow - session.ChannelJoinedAt).TotalSeconds; + } + _ = RestApi.Instance.SaveGameDataAsync( + session.Username, + player.PosX, player.PosY, player.PosZ, player.RotY, + playTimeDelta > 0 ? playTimeDelta : null + ); + } + if (cm.RemoveUser(hashKey)) { Log.Information("[GameServer] 세션 해제 HashKey={Key} Reason={Reason}", hashKey, info.Reason); @@ -524,6 +539,10 @@ public class GameServer : ServerBase newPlayer.AttackPower = (float)profile.AttackPower; newPlayer.AttackRange = (float)profile.AttackRange; newPlayer.SprintMultiplier = (float)profile.SprintMultiplier; + newPlayer.PosX = (float)profile.LastPosX; + newPlayer.PosY = (float)profile.LastPosY; + newPlayer.PosZ = (float)profile.LastPosZ; + newPlayer.RotY = (float)profile.LastRotY; Log.Information("[GameServer] 프로필 로드 완료 Username={Username} Level={Level} MaxHp={MaxHp}", username, profile.Level, profile.MaxHp); } @@ -540,6 +559,10 @@ public class GameServer : ServerBase } } + // 채널 입장 시각 기록 (플레이타임 계산용) + ((Session)peer.Tag).ChannelJoinedAt = DateTime.UtcNow; + + // 채널에 추가 cm.AddUser(packet.ChannelId, hashKey, newPlayer, peer); Log.Debug("[GameServer] INTO_CHANNEL HashKey={Key} ChannelId={ChannelId}", hashKey, packet.ChannelId); @@ -921,6 +944,84 @@ public class GameServer : ServerBase BroadcastToChannel(channelId, data); // 채널 전체 파티 목록 갱신 break; } + case PartyUpdateType.INVITE: + { + // 리더만 초대 가능 + PartyInfo? myParty = pm.GetPartyByPlayer(hashKey); + if (myParty == null || myParty.LeaderId != hashKey) + { + SendError(peer, ErrorCode.PARTY_JOIN_FAILED); + return; + } + + if (myParty.GetPartyMemberCount() >= PartyInfo.partyMemberMax) + { + SendError(peer, ErrorCode.PARTY_JOIN_FAILED); + return; + } + + // 대상 플레이어가 같은 채널에 있는지 확인 + int targetId = req.TargetPlayerId; + Channel.Channel? ch = cm.GetChannel(channelId); + if (ch == null || ch.GetPlayer(targetId) == null) + { + SendError(peer, ErrorCode.PARTY_JOIN_FAILED); + return; + } + + // 대상에게 초대 알림 전송 + NetPeer? targetPeer = ch.GetPeer(targetId); + if (targetPeer == null) + { + SendError(peer, ErrorCode.PARTY_JOIN_FAILED); + return; + } + + UpdatePartyPacket inviteNotify = new() + { + PartyId = myParty.PartyId, + Type = PartyUpdateType.INVITE, + LeaderId = hashKey, + PlayerId = targetId, + PartyName = myParty.PartyName + }; + byte[] inviteData = PacketSerializer.Serialize((ushort)PacketCode.UPDATE_PARTY, inviteNotify); + SendTo(targetPeer, inviteData); + break; + } + case PartyUpdateType.KICK: + { + // 리더만 추방 가능 + PartyInfo? myParty2 = pm.GetPartyByPlayer(hashKey); + if (myParty2 == null || myParty2.LeaderId != hashKey) + { + SendError(peer, ErrorCode.PARTY_DELETE_FAILED); + return; + } + + int kickTarget = req.TargetPlayerId; + if (kickTarget == hashKey) + { + return; // 자기 자신은 추방 불가 + } + + if (!pm.LeaveParty(kickTarget, out PartyInfo? kickedParty) || kickedParty == null) + { + SendError(peer, ErrorCode.PARTY_NOT_IN_PARTY); + return; + } + + UpdatePartyPacket kickNotify = new() + { + PartyId = kickedParty.PartyId, + Type = PartyUpdateType.KICK, + LeaderId = kickedParty.LeaderId, + PlayerId = kickTarget + }; + byte[] kickData = PacketSerializer.Serialize((ushort)PacketCode.UPDATE_PARTY, kickNotify); + BroadcastToChannel(channelId, kickData); + break; + } } } diff --git a/MMOTestServer/MMOserver/Game/Player.cs b/MMOTestServer/MMOserver/Game/Player.cs index da75369..583ec1f 100644 --- a/MMOTestServer/MMOserver/Game/Player.cs +++ b/MMOTestServer/MMOserver/Game/Player.cs @@ -118,4 +118,5 @@ public class Player get; set; } + } diff --git a/MMOTestServer/MMOserver/Packet/PacketBody.cs b/MMOTestServer/MMOserver/Packet/PacketBody.cs index 572d160..babf7c6 100644 --- a/MMOTestServer/MMOserver/Packet/PacketBody.cs +++ b/MMOTestServer/MMOserver/Packet/PacketBody.cs @@ -661,7 +661,9 @@ public enum PartyUpdateType DELETE, JOIN, LEAVE, - UPDATE + UPDATE, + INVITE, // 리더가 대상 플레이어에게 초대 전송 + KICK // 리더가 파티원을 추방 } // REQUEST_PARTY (클라 -> 서버) - CREATE: PartyName 사용 / JOIN·LEAVE·DELETE: PartyId 사용 @@ -688,6 +690,13 @@ public class RequestPartyPacket get; set; } // CREATE 시 사용 + + [ProtoMember(4)] + public int TargetPlayerId + { + get; + set; + } // INVITE, KICK 시 사용 } // ============================================================ diff --git a/MMOTestServer/ServerLib/Service/Session.cs b/MMOTestServer/ServerLib/Service/Session.cs index 9e3e7de..0afec93 100644 --- a/MMOTestServer/ServerLib/Service/Session.cs +++ b/MMOTestServer/ServerLib/Service/Session.cs @@ -67,6 +67,9 @@ public class Session RateLimitViolations = 0; } + // 채널 입장 시각 (플레이타임 계산용) + public DateTime ChannelJoinedAt { get; set; } + public Session(int hashKey, NetPeer peer, int maxPacketsPerSecond = 60) { HashKey = hashKey;