first commit

This commit is contained in:
qornwh1
2026-02-28 14:16:07 +09:00
commit 30457819b1
28 changed files with 3006 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ClientTester</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LiteNetLib" Version="2.0.2" />
<PackageReference Include="protobuf-net" Version="3.2.56" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,107 @@
using LiteNetLib.Utils;
using Serilog;
namespace ClientTester.EchoDummyService;
public class DummyClientService
{
private readonly List<DummyClients> _clients;
private readonly int _sendInterval;
public DummyClientService(int count, string ip, int port, string key, int sendIntervalMs = 1000)
{
_sendInterval = sendIntervalMs;
_clients = Enumerable.Range(0, count)
.Select(i => new DummyClients(i, ip, port, key))
.ToList();
Log.Information("[SERVICE] {Count}개 클라이언트 생성 → {Ip}:{Port}", count, ip, port);
}
public async Task RunAsync(CancellationToken ct)
{
await Task.WhenAll(
PollLoopAsync(ct),
SendLoopAsync(ct)
);
}
private async Task PollLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
foreach (var c in _clients)
c.PollEvents();
try { await Task.Delay(15, ct); }
catch (OperationCanceledException) { break; }
}
}
private async Task SendLoopAsync(CancellationToken ct)
{
try { await Task.Delay(500, ct); }
catch (OperationCanceledException) { return; }
int tick = 0;
while (!ct.IsCancellationRequested)
{
int sent = 0;
foreach (var client in _clients)
{
client.SendPing();
if (client.Peer != null) sent++;
}
Log.Information("[TICK {Tick:000}] {Sent}/{Total} 전송", tick, sent, _clients.Count);
tick++;
try { await Task.Delay(_sendInterval, ct); }
catch (OperationCanceledException) { break; }
}
}
public void PrintStats()
{
int totalSent = 0, totalRecv = 0;
int connected = 0;
Log.Information("════════════ Performance Report ════════════");
double totalAvgRtt = 0;
foreach (var c in _clients)
{
var stats = c.Peer?.Statistics;
long loss = stats?.PacketLoss ?? 0;
float lossPct = stats?.PacketLossPercent ?? 0f;
Log.Information(
"[Client {ClientId:00}] Sent={Sent} Recv={Recv} | Loss={Loss}({LossPct:F1}%) AvgRTT={AvgRtt:F3}ms LastRTT={LastRtt:F3}ms",
c.ClientId, c.SentCount, c.ReceivedCount, loss, lossPct, c.AvgRttMs, c.LastRttMs);
totalSent += c.SentCount;
totalRecv += c.ReceivedCount;
totalAvgRtt += c.AvgRttMs;
if (c.Peer != null) connected++;
}
double avgRtt = connected > 0 ? totalAvgRtt / connected : 0;
Log.Information("────────────────────────────────────────────");
Log.Information(
"[TOTAL] Sent={Sent} Recv={Recv} Connected={Connected}/{Total} AvgRTT={AvgRtt:F3}ms",
totalSent, totalRecv, connected, _clients.Count, avgRtt);
Log.Information("════════════════════════════════════════════");
}
public void Stop()
{
foreach (var c in _clients)
c.Stop();
Log.Information("[SERVICE] 모든 클라이언트 종료됨.");
}
}

View File

@@ -0,0 +1,88 @@
using System.Diagnostics;
using LiteNetLib;
using LiteNetLib.Utils;
using Serilog;
namespace ClientTester.EchoDummyService;
public class DummyClients
{
public NetManager Manager;
public EventBasedNetListener Listener;
public NetPeer? Peer;
public int ClientId;
// seq → 송신 타임스탬프 (Stopwatch tick)
private readonly Dictionary<int, long> _pendingPings = new();
private int _seqNumber;
// 통계
public int SentCount;
public int ReceivedCount;
public double LastRttMs;
public double TotalRttMs;
public int RttCount;
public DummyClients(int clientId, string ip, int port, string key)
{
ClientId = clientId;
Listener = new EventBasedNetListener();
Manager = new NetManager(Listener);
Listener.PeerConnectedEvent += peer =>
{
Peer = peer;
Log.Information("[Client {ClientId:00}] 연결됨", ClientId);
};
Listener.NetworkReceiveEvent += (peer, reader, channel, deliveryMethod) =>
{
var msg = reader.GetString();
// "ack:seq:{seqNum}" 파싱
var parts = msg.Split(':');
if (parts.Length == 3 && parts[0] == "ack" && parts[1] == "seq"
&& int.TryParse(parts[2], out int seq)
&& _pendingPings.TryGetValue(seq, out long sentTick))
{
double rttMs = (Stopwatch.GetTimestamp() - sentTick)
* 1000.0 / Stopwatch.Frequency;
LastRttMs = rttMs;
TotalRttMs += rttMs;
RttCount++;
_pendingPings.Remove(seq);
}
ReceivedCount++;
reader.Recycle();
};
Listener.PeerDisconnectedEvent += (peer, info) =>
{
Log.Warning("[Client {ClientId:00}] 연결 끊김: {Reason}", ClientId, info.Reason);
Peer = null;
};
Manager.Start();
Manager.Connect(ip, port, key);
}
public void SendPing()
{
if (Peer is null) return;
int seq = _seqNumber++;
_pendingPings[seq] = Stopwatch.GetTimestamp();
var writer = new NetDataWriter();
writer.Put($"seq:{seq}");
Peer.Send(writer, DeliveryMethod.ReliableOrdered);
SentCount++;
}
public double AvgRttMs => RttCount > 0 ? TotalRttMs / RttCount : 0.0;
public void PollEvents() => Manager.PollEvents();
public void Stop() => Manager.Stop();
}

View File

@@ -0,0 +1,604 @@
using ProtoBuf;
namespace ClientTester.Packet;
// ============================================================
// 공통 타입
// ============================================================
[ProtoContract]
public class Vector3
{
[ProtoMember(1)]
public float X
{
get;
set;
}
[ProtoMember(2)]
public float Y
{
get;
set;
}
[ProtoMember(3)]
public float Z
{
get;
set;
}
}
[ProtoContract]
public class PlayerInfo
{
[ProtoMember(1)]
public int PlayerId
{
get;
set;
}
[ProtoMember(2)]
public string Nickname
{
get;
set;
}
[ProtoMember(3)]
public int Level
{
get;
set;
}
[ProtoMember(4)]
public int Hp
{
get;
set;
}
[ProtoMember(5)]
public int MaxHp
{
get;
set;
}
[ProtoMember(6)]
public int Mp
{
get;
set;
}
[ProtoMember(7)]
public int MaxMp
{
get;
set;
}
[ProtoMember(8)]
public Vector3 Position
{
get;
set;
}
[ProtoMember(9)]
public float RotY
{
get;
set;
}
}
[ProtoContract]
public class ItemInfo
{
[ProtoMember(1)]
public int ItemId
{
get;
set;
}
[ProtoMember(2)]
public int Count
{
get;
set;
}
}
// ============================================================
// 인증
// ============================================================
// RECV_TOKEN
[ProtoContract]
public class RecvTokenPacket
{
[ProtoMember(1)]
public string Token
{
get;
set;
}
}
// LOAD_GAME
[ProtoContract]
public class LoadGamePacket
{
[ProtoMember(1)]
public bool IsAccepted
{
get;
set;
}
[ProtoMember(2)]
public PlayerInfo Player
{
get;
set;
}
}
// ============================================================
// 로비
// ============================================================
// INTO_LOBBY
[ProtoContract]
public class IntoLobbyPacket
{
[ProtoMember(1)]
public List<PlayerInfo> Players
{
get;
set;
}
}
// EXIT_LOBBY
[ProtoContract]
public class ExitLobbyPacket
{
[ProtoMember(1)]
public int PlayerId
{
get;
set;
}
}
// ============================================================
// 인스턴스 던전
// ============================================================
public enum BossState
{
START,
END,
PHASE_CHANGE
}
public enum BossResult
{
SUCCESS,
FAIL
}
// INTO_INSTANCE
[ProtoContract]
public class IntoInstancePacket
{
[ProtoMember(1)]
public int InstanceId
{
get;
set;
}
[ProtoMember(2)]
public int BossId
{
get;
set;
}
[ProtoMember(3)]
public List<int> PlayerIds
{
get;
set;
}
}
// UPDATE_BOSS
[ProtoContract]
public class UpdateBossPacket
{
[ProtoMember(1)]
public BossState State
{
get;
set;
}
[ProtoMember(2)]
public int Phase
{
get;
set;
}
[ProtoMember(3)]
public BossResult Result
{
get;
set;
} // END일 때만 유효
}
// REWARD_INSTANCE
[ProtoContract]
public class RewardInstancePacket
{
[ProtoMember(1)]
public int Exp
{
get;
set;
}
[ProtoMember(2)]
public List<ItemInfo> Items
{
get;
set;
}
}
// EXIT_INSTANCE
[ProtoContract]
public class ExitInstancePacket
{
[ProtoMember(1)]
public int PlayerId
{
get;
set;
}
}
// ============================================================
// 파티
// ============================================================
public enum PartyUpdateType
{
CREATE,
DELETE
}
public enum UserPartyUpdateType
{
JOIN,
LEAVE
}
// UPDATE_PARTY
[ProtoContract]
public class UpdatePartyPacket
{
[ProtoMember(1)]
public int PartyId
{
get;
set;
}
[ProtoMember(2)]
public PartyUpdateType Type
{
get;
set;
}
[ProtoMember(3)]
public int LeaderId
{
get;
set;
}
}
// UPDATE_USER_PARTY
[ProtoContract]
public class UpdateUserPartyPacket
{
[ProtoMember(1)]
public int PartyId
{
get;
set;
}
[ProtoMember(2)]
public int PlayerId
{
get;
set;
}
[ProtoMember(3)]
public UserPartyUpdateType Type
{
get;
set;
}
}
// ============================================================
// 플레이어
// ============================================================
public enum PlayerActionType
{
IDLE,
MOVE,
ATTACK,
SKILL,
DODGE,
DIE,
REVIVE
}
// TRANSFORM_PLAYER
[ProtoContract]
public class TransformPlayerPacket
{
[ProtoMember(1)]
public int PlayerId
{
get;
set;
}
[ProtoMember(2)]
public Vector3 Position
{
get;
set;
}
[ProtoMember(3)]
public float RotY
{
get;
set;
}
}
// ACTION_PLAYER
[ProtoContract]
public class ActionPlayerPacket
{
[ProtoMember(1)]
public int PlayerId
{
get;
set;
}
[ProtoMember(2)]
public PlayerActionType Action
{
get;
set;
}
[ProtoMember(3)]
public int SkillId
{
get;
set;
} // ATTACK, SKILL일 때
[ProtoMember(4)]
public int TargetId
{
get;
set;
} // 공격 대상
}
// STATE_PLAYER
[ProtoContract]
public class StatePlayerPacket
{
[ProtoMember(1)]
public int PlayerId
{
get;
set;
}
[ProtoMember(2)]
public int Hp
{
get;
set;
}
[ProtoMember(3)]
public int MaxHp
{
get;
set;
}
[ProtoMember(4)]
public int Mp
{
get;
set;
}
[ProtoMember(5)]
public int MaxMp
{
get;
set;
}
}
// ============================================================
// NPC
// ============================================================
public enum NpcActionType
{
IDLE,
MOVE,
ATTACK,
SKILL,
DIE
}
// TRANSFORM_NPC
[ProtoContract]
public class TransformNpcPacket
{
[ProtoMember(1)]
public int NpcId
{
get;
set;
}
[ProtoMember(2)]
public Vector3 Position
{
get;
set;
}
[ProtoMember(3)]
public float RotY
{
get;
set;
}
}
// ACTION_NPC
[ProtoContract]
public class ActionNpcPacket
{
[ProtoMember(1)]
public int NpcId
{
get;
set;
}
[ProtoMember(2)]
public NpcActionType Action
{
get;
set;
}
[ProtoMember(3)]
public int PatternId
{
get;
set;
} // 사용 패턴/스킬 번호
[ProtoMember(4)]
public int TargetId
{
get;
set;
}
}
// STATE_NPC
[ProtoContract]
public class StateNpcPacket
{
[ProtoMember(1)]
public int NpcId
{
get;
set;
}
[ProtoMember(2)]
public int Hp
{
get;
set;
}
[ProtoMember(3)]
public int MaxHp
{
get;
set;
}
[ProtoMember(4)]
public int Phase
{
get;
set;
}
}
// ============================================================
// 데미지
// ============================================================
// DAMAGE
[ProtoContract]
public class DamagePacket
{
[ProtoMember(1)]
public int AttackerId
{
get;
set;
}
[ProtoMember(2)]
public int TargetId
{
get;
set;
}
[ProtoMember(3)]
public int Amount
{
get;
set;
}
[ProtoMember(4)]
public bool IsCritical
{
get;
set;
}
}

View File

@@ -0,0 +1,61 @@
namespace ClientTester.Packet;
public enum PacketCode
{
NONE,
// 초기 클라이언트 시작시 jwt토큰 받아옴
RECV_TOKEN,
// jwt토큰 검증후 게임에 들어갈지 말지 (내 데이터도 전송)
LOAD_GAME,
// 마을(로비)진입시 모든 데이터 로드
INTO_LOBBY,
// 로비 나가기
EXIT_LOBBY,
// 인스턴스 던전 입장
INTO_INSTANCE,
// 결과 보상
REWARD_INSTANCE,
// 보스전 (시작, 종료)
UPDATE_BOSS,
// 인스턴스 던전 퇴장
EXIT_INSTANCE,
// 파티 (생성, 삭제)
UPDATE_PARTY,
// 파티 유저 업데이트(추가 삭제)
UPDATE_USER_PARTY,
// 플레이어 위치, 방향
TRANSFORM_PLAYER,
// 플레이어 행동 업데이트
ACTION_PLAYER,
// 플레이어 스테이트 업데이트
STATE_PLAYER,
// NPC 위치, 방향
TRANSFORM_NPC,
// NPC 행동 업데이트
ACTION_NPC,
// NPC 스테이트 업데이트
STATE_NPC,
// 데미지 UI 전달
DAMAGE
}
public class PacketHeader
{
public PacketCode Code;
public int BodyLength;
}

View File

@@ -0,0 +1,28 @@
using ClientTester.EchoDummyService;
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateLogger();
const string SERVER_IP = "localhost";
const int SERVER_PORT = 9500;
const string CONNECTION_KEY = "game";
const int CLIENT_COUNT = 1000;
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Log.Warning("[SHUTDOWN] Ctrl+C 감지, 종료 중...");
cts.Cancel();
};
var service = new DummyClientService(CLIENT_COUNT, SERVER_IP, SERVER_PORT, CONNECTION_KEY, 100);
await service.RunAsync(cts.Token);
service.PrintStats();
service.Stop();
Log.CloseAndFlush();