Fix: 플레이어 프로필 DB 연동, 파티 초대/추방 프로토콜 구현
- 채널 입장 시 API 서버에서 플레이어 프로필 로드 (레벨/스탯/위치) - 채널 퇴장 시 위치/플레이타임 DB 저장 (SaveGameDataAsync) - Player.cs에 AttackPower/AttackRange/SprintMultiplier/Experience 필드 추가 - ToPlayerInfo에서 전투 스탯 매핑 추가 - Session에 ChannelJoinedAt 추가 (플레이타임 계산용) - PartyUpdateType에 INVITE/KICK 추가 - RequestPartyPacket에 TargetPlayerId 필드 추가 - GameServer에 INVITE/KICK 핸들러 구현 - Channel에 GetPeer() 메서드 추가 - RestApi에 GetPlayerProfileAsync/SaveGameDataAsync 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -663,7 +663,9 @@ public enum PartyUpdateType
|
|||||||
DELETE,
|
DELETE,
|
||||||
JOIN,
|
JOIN,
|
||||||
LEAVE,
|
LEAVE,
|
||||||
UPDATE
|
UPDATE,
|
||||||
|
INVITE,
|
||||||
|
KICK
|
||||||
}
|
}
|
||||||
|
|
||||||
// REQUEST_PARTY (클라 -> 서버) - CREATE: PartyName 사용 / JOIN·LEAVE·DELETE: PartyId 사용
|
// REQUEST_PARTY (클라 -> 서버) - CREATE: PartyName 사용 / JOIN·LEAVE·DELETE: PartyId 사용
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ public class RestApi : Singleton<RestApi>
|
|||||||
|
|
||||||
[JsonPropertyName("sprintMultiplier")]
|
[JsonPropertyName("sprintMultiplier")]
|
||||||
public double SprintMultiplier { get; set; }
|
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<RestApi>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 게임 데이터 저장 (채널 퇴장 시 위치/플레이타임 저장)
|
||||||
|
public async Task<bool> 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<string, object?>();
|
||||||
|
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
|
private sealed class BossRaidAccessResponse
|
||||||
{
|
{
|
||||||
[JsonPropertyName("roomId")]
|
[JsonPropertyName("roomId")]
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ public class Channel
|
|||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 특정 유저의 NetPeer 반환
|
||||||
|
public NetPeer? GetPeer(int userId)
|
||||||
|
{
|
||||||
|
connectPeers.TryGetValue(userId, out NetPeer? peer);
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
public int HasUser(int userId)
|
public int HasUser(int userId)
|
||||||
{
|
{
|
||||||
if (connectUsers.ContainsKey(userId))
|
if (connectUsers.ContainsKey(userId))
|
||||||
|
|||||||
@@ -207,6 +207,21 @@ public class GameServer : ServerBase
|
|||||||
int channelId = cm.HasUser(hashKey);
|
int channelId = cm.HasUser(hashKey);
|
||||||
Player? player = channelId >= 0 ? cm.GetChannel(channelId).GetPlayer(hashKey) : null;
|
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))
|
if (cm.RemoveUser(hashKey))
|
||||||
{
|
{
|
||||||
Log.Information("[GameServer] 세션 해제 HashKey={Key} Reason={Reason}", hashKey, info.Reason);
|
Log.Information("[GameServer] 세션 해제 HashKey={Key} Reason={Reason}", hashKey, info.Reason);
|
||||||
@@ -524,6 +539,10 @@ public class GameServer : ServerBase
|
|||||||
newPlayer.AttackPower = (float)profile.AttackPower;
|
newPlayer.AttackPower = (float)profile.AttackPower;
|
||||||
newPlayer.AttackRange = (float)profile.AttackRange;
|
newPlayer.AttackRange = (float)profile.AttackRange;
|
||||||
newPlayer.SprintMultiplier = (float)profile.SprintMultiplier;
|
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}",
|
Log.Information("[GameServer] 프로필 로드 완료 Username={Username} Level={Level} MaxHp={MaxHp}",
|
||||||
username, profile.Level, profile.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);
|
cm.AddUser(packet.ChannelId, hashKey, newPlayer, peer);
|
||||||
Log.Debug("[GameServer] INTO_CHANNEL HashKey={Key} ChannelId={ChannelId}", hashKey, packet.ChannelId);
|
Log.Debug("[GameServer] INTO_CHANNEL HashKey={Key} ChannelId={ChannelId}", hashKey, packet.ChannelId);
|
||||||
|
|
||||||
@@ -921,6 +944,84 @@ public class GameServer : ServerBase
|
|||||||
BroadcastToChannel(channelId, data); // 채널 전체 파티 목록 갱신
|
BroadcastToChannel(channelId, data); // 채널 전체 파티 목록 갱신
|
||||||
break;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,4 +118,5 @@ public class Player
|
|||||||
get;
|
get;
|
||||||
set;
|
set;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -661,7 +661,9 @@ public enum PartyUpdateType
|
|||||||
DELETE,
|
DELETE,
|
||||||
JOIN,
|
JOIN,
|
||||||
LEAVE,
|
LEAVE,
|
||||||
UPDATE
|
UPDATE,
|
||||||
|
INVITE, // 리더가 대상 플레이어에게 초대 전송
|
||||||
|
KICK // 리더가 파티원을 추방
|
||||||
}
|
}
|
||||||
|
|
||||||
// REQUEST_PARTY (클라 -> 서버) - CREATE: PartyName 사용 / JOIN·LEAVE·DELETE: PartyId 사용
|
// REQUEST_PARTY (클라 -> 서버) - CREATE: PartyName 사용 / JOIN·LEAVE·DELETE: PartyId 사용
|
||||||
@@ -688,6 +690,13 @@ public class RequestPartyPacket
|
|||||||
get;
|
get;
|
||||||
set;
|
set;
|
||||||
} // CREATE 시 사용
|
} // CREATE 시 사용
|
||||||
|
|
||||||
|
[ProtoMember(4)]
|
||||||
|
public int TargetPlayerId
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set;
|
||||||
|
} // INVITE, KICK 시 사용
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ public class Session
|
|||||||
RateLimitViolations = 0;
|
RateLimitViolations = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 채널 입장 시각 (플레이타임 계산용)
|
||||||
|
public DateTime ChannelJoinedAt { get; set; }
|
||||||
|
|
||||||
public Session(int hashKey, NetPeer peer, int maxPacketsPerSecond = 60)
|
public Session(int hashKey, NetPeer peer, int maxPacketsPerSecond = 60)
|
||||||
{
|
{
|
||||||
HashKey = hashKey;
|
HashKey = hashKey;
|
||||||
|
|||||||
Reference in New Issue
Block a user