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:
2026-03-19 21:01:56 +09:00
parent f77219e9d2
commit 64855c5a69
7 changed files with 170 additions and 2 deletions

View File

@@ -663,7 +663,9 @@ public enum PartyUpdateType
DELETE,
JOIN,
LEAVE,
UPDATE
UPDATE,
INVITE,
KICK
}
// REQUEST_PARTY (클라 -> 서버) - CREATE: PartyName 사용 / JOIN·LEAVE·DELETE: PartyId 사용

View File

@@ -129,6 +129,18 @@ public class RestApi : Singleton<RestApi>
[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<RestApi>
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
{
[JsonPropertyName("roomId")]

View File

@@ -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))

View File

@@ -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;
}
}
}

View File

@@ -118,4 +118,5 @@ public class Player
get;
set;
}
}

View File

@@ -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 시 사용
}
// ============================================================

View File

@@ -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;