From 30457819b1285a433378d5a57aa00a85a83ce22f Mon Sep 17 00:00:00 2001 From: qornwh1 Date: Sat, 28 Feb 2026 14:16:07 +0900 Subject: [PATCH] first commit --- .gitignore | 85 +++ ClientTester/.editorconfig | 361 +++++++++++ ClientTester/ClientTester.sln | 16 + .../EchoClientTester/EchoClientTester.csproj | 18 + .../EchoDummyService/DummyClientService.cs | 107 ++++ .../EchoDummyService/DummyClients.cs | 88 +++ .../EchoClientTester/Packet/PacketBody.cs | 604 ++++++++++++++++++ .../EchoClientTester/Packet/PacketHeader.cs | 61 ++ ClientTester/EchoClientTester/Program.cs | 28 + ClientTester/TempServer/Program.cs | 48 ++ ClientTester/TempServer/TempServer.csproj | 14 + MMOTestServer/.dockerignore | 26 + MMOTestServer/.editorconfig | 362 +++++++++++ MMOTestServer/MMOserver.sln | 27 + MMOTestServer/MMOserver/Dockerfile | 24 + MMOTestServer/MMOserver/Game/GameServer.cs | 28 + MMOTestServer/MMOserver/MMOserver.csproj | 23 + MMOTestServer/MMOserver/Packet/PacketBody.cs | 604 ++++++++++++++++++ .../MMOserver/Packet/PacketHeader.cs | 61 ++ MMOTestServer/MMOserver/Program.cs | 46 ++ .../ServerLib/Packet/PacketSerializer.cs | 53 ++ MMOTestServer/ServerLib/Packet/PacketType.cs | 11 + MMOTestServer/ServerLib/ServerLib.csproj | 17 + MMOTestServer/ServerLib/Service/ServerBase.cs | 239 +++++++ MMOTestServer/ServerLib/Service/Session.cs | 15 + .../ServerLib/Service/SessionManager.cs | 24 + MMOTestServer/compose.yaml | 9 + MMOTestServer/global.json | 7 + 28 files changed, 3006 insertions(+) create mode 100644 .gitignore create mode 100644 ClientTester/.editorconfig create mode 100644 ClientTester/ClientTester.sln create mode 100644 ClientTester/EchoClientTester/EchoClientTester.csproj create mode 100644 ClientTester/EchoClientTester/EchoDummyService/DummyClientService.cs create mode 100644 ClientTester/EchoClientTester/EchoDummyService/DummyClients.cs create mode 100644 ClientTester/EchoClientTester/Packet/PacketBody.cs create mode 100644 ClientTester/EchoClientTester/Packet/PacketHeader.cs create mode 100644 ClientTester/EchoClientTester/Program.cs create mode 100644 ClientTester/TempServer/Program.cs create mode 100644 ClientTester/TempServer/TempServer.csproj create mode 100644 MMOTestServer/.dockerignore create mode 100644 MMOTestServer/.editorconfig create mode 100644 MMOTestServer/MMOserver.sln create mode 100644 MMOTestServer/MMOserver/Dockerfile create mode 100644 MMOTestServer/MMOserver/Game/GameServer.cs create mode 100644 MMOTestServer/MMOserver/MMOserver.csproj create mode 100644 MMOTestServer/MMOserver/Packet/PacketBody.cs create mode 100644 MMOTestServer/MMOserver/Packet/PacketHeader.cs create mode 100644 MMOTestServer/MMOserver/Program.cs create mode 100644 MMOTestServer/ServerLib/Packet/PacketSerializer.cs create mode 100644 MMOTestServer/ServerLib/Packet/PacketType.cs create mode 100644 MMOTestServer/ServerLib/ServerLib.csproj create mode 100644 MMOTestServer/ServerLib/Service/ServerBase.cs create mode 100644 MMOTestServer/ServerLib/Service/Session.cs create mode 100644 MMOTestServer/ServerLib/Service/SessionManager.cs create mode 100644 MMOTestServer/compose.yaml create mode 100644 MMOTestServer/global.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d60f0d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,85 @@ +# ============================================================ +# C# / .NET +# ============================================================ +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates +*.nupkg +*.snupkg +*.log +[Ll]og/ +[Ll]ogs/ +*.tmp +*.temp +project.lock.json +project.fragment.lock.json +artifacts/ + +# NuGet +.nuget/ +packages/ +*.nupkg + +# ============================================================ +# IDE - Visual Studio +# ============================================================ +.vs/ +*.rsuser +*.MojoProj +*.pidb +*.svclog +*.scc +_Chutzpah* +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.opendb +$tf/ + +# ============================================================ +# IDE - JetBrains (Rider / IntelliJ IDEA) +# ============================================================ +.idea/ +*.iml +*.iws +*.ipr +.idea_modules/ +out/ +atlassian-ide-plugin.xml + +# ============================================================ +# IDE - VS Code +# ============================================================ +.vscode/ + +# ============================================================ +# AI - Claude +# ============================================================ +.claude/ + +# ============================================================ +# OS +# ============================================================ + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +*.lnk + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes diff --git a/ClientTester/.editorconfig b/ClientTester/.editorconfig new file mode 100644 index 0000000..c5a4f62 --- /dev/null +++ b/ClientTester/.editorconfig @@ -0,0 +1,361 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.cs] +# ------------------------------------------------------------------------- +# 중괄호 Allman 스타일 +# ------------------------------------------------------------------------- +# Good: Bad: +# if (isAlive) if (isAlive) { +# { TakeDamage(); +# TakeDamage(); } +# } +csharp_new_line_before_open_brace = all + +# ------------------------------------------------------------------------- +# 중괄호 항상 사용 +# ------------------------------------------------------------------------- +# Good: Bad: +# if (isAlive) if (isAlive) +# { TakeDamage(); +# TakeDamage(); +# } +csharp_prefer_braces = true:warning + +# ------------------------------------------------------------------------- +# 접근 제한자 항상 명시 +# ------------------------------------------------------------------------- +# Good: Bad: +# private int health; int health; +# public float moveSpeed; float moveSpeed; +dotnet_style_require_accessibility_modifiers = always:warning + +# ------------------------------------------------------------------------- +# this. 한정자 허용 (필드와 매개변수 이름이 같을 때 필요) +# ------------------------------------------------------------------------- +# 허용: this.stateContext = stateContext; (매개변수와 필드명 동일 시) +# 불필요: this.Initialize(); (모호하지 않은 경우) +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# ------------------------------------------------------------------------- +# var 사용 금지 +# ------------------------------------------------------------------------- +# Good: Bad: +# Enemy enemy = GetEnemy(); var enemy = GetEnemy(); +# int count = 0; var count = 0; +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = false:warning +csharp_style_var_elsewhere = false:warning + +# ------------------------------------------------------------------------- +# null 체크 스타일 강제 +# ------------------------------------------------------------------------- +# Good: Bad: +# animator?.Play("Run"); if (animator != null) animator.Play("Run"); +# string name = playerName ?? "Unknown"; string name = playerName != null ? playerName : "Unknown"; +# if (obj is null) { } if (object.ReferenceEquals(obj, null)) { } +csharp_style_conditional_delegate_call = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_coalesce_expression = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning + +# ------------------------------------------------------------------------- +# 패턴 매칭 강제 (as + null 체크 대신 is 패턴 사용) +# ------------------------------------------------------------------------- +# Good: Bad: +# if (obj is Enemy enemy) Enemy enemy = obj as Enemy; +# { if (enemy != null) +# enemy.TakeDamage(); { +# } enemy.TakeDamage(); +# } +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +# not 패턴 (C# 9+) +# Good: if (obj is not null) Bad: if (!(obj is null)) +# Good: if (enemy is not Dead) Bad: if (!(enemy is Dead)) +csharp_style_prefer_not_pattern = true:warning + +# ------------------------------------------------------------------------- +# throw 표현식 강제 +# ------------------------------------------------------------------------- +# Good: +# target = player ?? throw new ArgumentNullException(nameof(player)); +# +# Bad: +# if (player == null) throw new ArgumentNullException(nameof(player)); +# target = player; +csharp_style_throw_expression = true:warning + +# ------------------------------------------------------------------------- +# 인라인 변수 선언 강제 +# ------------------------------------------------------------------------- +# Good: Bad: +# TryGetComponent(out Rigidbody rb); Rigidbody rb; +# TryGetComponent(out rb); +csharp_style_inlined_variable_declaration = true:warning + +# ------------------------------------------------------------------------- +# 튜플 해체 선언 +# ------------------------------------------------------------------------- +# Good: Bad: +# var (x, y) = GetPosition(); var pos = GetPosition(); +# var x = pos.x; var y = pos.y; +csharp_style_deconstructed_variable_declaration = true:suggestion + +# ------------------------------------------------------------------------- +# 간단한 using 선언문 +# ------------------------------------------------------------------------- +# Good: Bad: +# using var stream = File.Open(...); using (var stream = File.Open(...)) +# { +# } +csharp_prefer_simple_using_statement = true:suggestion + +# ------------------------------------------------------------------------- +# 인덱스 / 범위 연산자 (C# 8+) +# ------------------------------------------------------------------------- +# Good: items[^1] Bad: items[items.Length - 1] +# Good: items[1..3] Bad: items.Skip(1).Take(2) +csharp_style_prefer_index_from_end = true:suggestion +csharp_style_prefer_range_operator = true:suggestion + +# ------------------------------------------------------------------------- +# switch 표현식 권장 +# ------------------------------------------------------------------------- +# Good: Bad: +# string label = state switch string label; +# { switch (state) +# GameState.Playing => "Playing", { +# GameState.Paused => "Paused", case GameState.Playing: label = "Playing"; break; +# _ => "Unknown" case GameState.Paused: label = "Paused"; break; +# }; default: label = "Unknown"; break; +# } +csharp_style_prefer_switch_expression = true:suggestion + +# ------------------------------------------------------------------------- +# 불필요한 코드 제거 +# ------------------------------------------------------------------------- +# object initializer +# Good: Bad: +# Enemy enemy = new Enemy Enemy enemy = new Enemy(); +# { enemy.hp = 100; +# hp = 100, enemy.name = "Goblin"; +# name = "Goblin" +# }; +# +# 미사용 반환값은 _ 로 명시적으로 버리기 +# Good: Bad: +# _ = TryGetComponent(out Rigidbody rb); TryGetComponent(out Rigidbody rb); +dotnet_style_object_initializer = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_remove_unnecessary_suppression_exclusions = true +csharp_style_unused_value_assignment_preference = discard_variable:warning +csharp_style_unused_value_expression_statement_preference = discard_variable:warning + +# ------------------------------------------------------------------------- +# 단순화 강제 +# ------------------------------------------------------------------------- +# auto property +# Good: Bad: +# public int Hp { get; private set; } private int hp; +# public int Hp { get { return hp; } } +# +# boolean 단순화 +# Good: Bad: +# return isAlive; return isAlive == true; +# +# 삼항 연산자 (suggestion - 복잡한 경우 강제 안 함) +# Good: Bad: +# int damage = isCrit ? 200 : 100; int damage; +# if (isCrit) damage = 200; +# else damage = 100; +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_simplified_boolean_expressions = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion + +# ------------------------------------------------------------------------- +# 복합 할당 연산자 +# ------------------------------------------------------------------------- +# Good: hp -= damage; Bad: hp = hp - damage; +# Good: score += point; Bad: score = score + point; +dotnet_style_prefer_compound_assignment = true:warning + +# ------------------------------------------------------------------------- +# 타입 예약어 강제 (BCL 타입명 대신 C# 예약어 사용) +# ------------------------------------------------------------------------- +# Good: Bad: +# int count = 0; Int32 count = 0; +# string name = "Player"; String name = "Player"; +# object obj = new Enemy(); Object obj = new Enemy(); +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# ------------------------------------------------------------------------- +# expression-bodied 프로퍼티/접근자 강제, 메서드/생성자/로컬함수 금지 +# ------------------------------------------------------------------------- +# Good: Bad: +# public int CurrentHp => currentHp; public int CurrentHp +# { +# get { return currentHp; } +# } +# +# 메서드 / 생성자 / 로컬 함수는 expression-bodied 금지 +# Good: Bad: +# public void TakeDamage(int damage) public void TakeDamage(int damage) => hp -= damage; +# { +# hp -= damage; +# } +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_methods = false:warning +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_local_functions = false:warning + +# ------------------------------------------------------------------------- +# 한 줄 블록 금지 (포맷터 적용 시 자동 정리) +# ------------------------------------------------------------------------- +# Good: Bad: +# if (isAlive) if (isAlive) { TakeDamage(); } +# { +# TakeDamage(); +# } +# 주의: 이 설정은 경고가 아닌 포맷터 실행 시에만 적용됩니다. +csharp_preserve_single_line_blocks = false +csharp_preserve_single_line_statements = false + +# ------------------------------------------------------------------------- +# using 정렬 +# ------------------------------------------------------------------------- +# Good: +# using System.Collections; +# using System.Collections.Generic; +# using UnityEngine; +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace:warning +# 네임스페이스는 폴더 구조와 일치 +# Good: Assets/Scripts/Player/ → namespace Project.Player +dotnet_style_namespace_match_folder = true:suggestion + +# ------------------------------------------------------------------------- +# 네이밍 규칙 +# ------------------------------------------------------------------------- +# 주의: 더 구체적인 규칙(const 등)을 먼저 선언해야 올바르게 적용됩니다. + +# 상수: UPPER_SNAKE_CASE (const modifier로 가장 구체적 → 최우선) +# Good: const int MAX_LEVEL = 100; Bad: const int MaxLevel = 100; +# Good: const float DEFAULT_SPEED = 5f; Bad: const float defaultSpeed = 5f; +dotnet_naming_rule.constants.severity = warning +dotnet_naming_rule.constants.symbols = constants +dotnet_naming_rule.constants.style = upper_snake_case + +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.upper_snake_case.capitalization = all_upper +dotnet_naming_style.upper_snake_case.word_separator = _ + +# private / protected 필드: camelCase (Unity 스타일 통일) +# Good: private int currentHp; Bad: private int _currentHp; +# Good: protected float baseSpeed; Bad: protected float _baseSpeed; +dotnet_naming_rule.private_fields.severity = warning +dotnet_naming_rule.private_fields.symbols = private_fields +dotnet_naming_rule.private_fields.style = camel_case_style + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal + +# public 필드: camelCase (Unity Inspector 노출 필드 기준 - 일반 C# 컨벤션과 다름) +# Good: public float moveSpeed; Bad: public float MoveSpeed; +# Good: public int maxHealth; Bad: public int MaxHealth; +dotnet_naming_rule.public_fields.severity = warning +dotnet_naming_rule.public_fields.symbols = public_fields +dotnet_naming_rule.public_fields.style = camel_case_style + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# 인터페이스: IPascalCase +# Good: interface IEnemy { } Bad: interface Enemy { } +# Good: interface IDamageable { } Bad: interface Damageable { } +dotnet_naming_rule.interfaces.severity = warning +dotnet_naming_rule.interfaces.symbols = interfaces +dotnet_naming_rule.interfaces.style = prefix_i + +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +dotnet_naming_style.prefix_i.capitalization = pascal_case +dotnet_naming_style.prefix_i.required_prefix = I + +# 클래스 / 구조체 / 열거형: PascalCase +# Good: class PlayerController { } Bad: class playerController { } +# Good: enum GameState { } Bad: enum gameState { } +dotnet_naming_rule.types.severity = warning +dotnet_naming_rule.types.symbols = types +dotnet_naming_rule.types.style = pascal_case_style + +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# 메서드 / 프로퍼티 / 이벤트: PascalCase +# Good: public void TakeDamage() { } Bad: public void takeDamage() { } +# Good: public int CurrentHp { } Bad: public int currentHp { } +# Good: public event Action OnDeath; Bad: public event Action onDeath; +dotnet_naming_rule.members.severity = warning +dotnet_naming_rule.members.symbols = members +dotnet_naming_rule.members.style = pascal_case_style + +dotnet_naming_symbols.members.applicable_kinds = method, property, event + +# 매개변수 / 로컬 변수: camelCase +# Good: void Init(int maxHp) { } Bad: void Init(int MaxHp) { } +# Good: float moveSpeed = 5f; Bad: float MoveSpeed = 5f; +dotnet_naming_rule.parameters_and_locals.severity = warning +dotnet_naming_rule.parameters_and_locals.symbols = parameters_and_locals +dotnet_naming_rule.parameters_and_locals.style = camel_case_style + +dotnet_naming_symbols.parameters_and_locals.applicable_kinds = parameter, local + +# enum 멤버: PascalCase +# Good: GameState.Playing Bad: GameState.playing +# Good: GameState.GameOver Bad: GameState.GAME_OVER +dotnet_naming_rule.enum_members.severity = warning +dotnet_naming_rule.enum_members.symbols = enum_members +dotnet_naming_rule.enum_members.style = pascal_case_style + +dotnet_naming_symbols.enum_members.applicable_kinds = enum_member + +[*.{json,yaml,yml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{asmdef,asmref}] +indent_size = 4 + +[*.shader] +indent_size = 4 + +[*.{compute,hlsl,cginc}] +indent_size = 4 + +[*.{uss,uxml}] +indent_size = 4 diff --git a/ClientTester/ClientTester.sln b/ClientTester/ClientTester.sln new file mode 100644 index 0000000..116c458 --- /dev/null +++ b/ClientTester/ClientTester.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoClientTester", "EchoClientTester\EchoClientTester.csproj", "{F093EF72-D2FA-4C17-AE64-49B6A9839EEB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F093EF72-D2FA-4C17-AE64-49B6A9839EEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F093EF72-D2FA-4C17-AE64-49B6A9839EEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F093EF72-D2FA-4C17-AE64-49B6A9839EEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F093EF72-D2FA-4C17-AE64-49B6A9839EEB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/ClientTester/EchoClientTester/EchoClientTester.csproj b/ClientTester/EchoClientTester/EchoClientTester.csproj new file mode 100644 index 0000000..2b3ac58 --- /dev/null +++ b/ClientTester/EchoClientTester/EchoClientTester.csproj @@ -0,0 +1,18 @@ + + + + Exe + net9.0 + enable + enable + ClientTester + + + + + + + + + + diff --git a/ClientTester/EchoClientTester/EchoDummyService/DummyClientService.cs b/ClientTester/EchoClientTester/EchoDummyService/DummyClientService.cs new file mode 100644 index 0000000..b3994dc --- /dev/null +++ b/ClientTester/EchoClientTester/EchoDummyService/DummyClientService.cs @@ -0,0 +1,107 @@ +using LiteNetLib.Utils; +using Serilog; + +namespace ClientTester.EchoDummyService; + +public class DummyClientService +{ + private readonly List _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] 모든 클라이언트 종료됨."); + } +} diff --git a/ClientTester/EchoClientTester/EchoDummyService/DummyClients.cs b/ClientTester/EchoClientTester/EchoDummyService/DummyClients.cs new file mode 100644 index 0000000..8af7723 --- /dev/null +++ b/ClientTester/EchoClientTester/EchoDummyService/DummyClients.cs @@ -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 _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(); +} diff --git a/ClientTester/EchoClientTester/Packet/PacketBody.cs b/ClientTester/EchoClientTester/Packet/PacketBody.cs new file mode 100644 index 0000000..a3fbcc1 --- /dev/null +++ b/ClientTester/EchoClientTester/Packet/PacketBody.cs @@ -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 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 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 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; + } +} diff --git a/ClientTester/EchoClientTester/Packet/PacketHeader.cs b/ClientTester/EchoClientTester/Packet/PacketHeader.cs new file mode 100644 index 0000000..b95aa68 --- /dev/null +++ b/ClientTester/EchoClientTester/Packet/PacketHeader.cs @@ -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; +} \ No newline at end of file diff --git a/ClientTester/EchoClientTester/Program.cs b/ClientTester/EchoClientTester/Program.cs new file mode 100644 index 0000000..73aed81 --- /dev/null +++ b/ClientTester/EchoClientTester/Program.cs @@ -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(); diff --git a/ClientTester/TempServer/Program.cs b/ClientTester/TempServer/Program.cs new file mode 100644 index 0000000..ae52914 --- /dev/null +++ b/ClientTester/TempServer/Program.cs @@ -0,0 +1,48 @@ +using LiteNetLib; +using LiteNetLib.Utils; + +const int PORT = 9500; +const string CONNECTION_KEY = "game"; + +var listener = new EventBasedNetListener(); +var server = new NetManager(listener); + +listener.ConnectionRequestEvent += request => +{ + request.AcceptIfKey(CONNECTION_KEY); +}; + +listener.PeerConnectedEvent += peer => +{ + Console.WriteLine($"[CONNECT] {peer.Port} (ID:{peer.Id}, Total:{server.ConnectedPeersCount})"); +}; + +listener.PeerDisconnectedEvent += (peer, info) => +{ + Console.WriteLine($"[DISCONNECT] {peer.Port} Reason:{info.Reason} (Remaining:{server.ConnectedPeersCount})"); +}; + +listener.NetworkReceiveEvent += (fromPeer, reader, channel, deliveryMethod) => +{ + var msg = reader.GetString(); + Console.WriteLine($"[RECV] ID:{fromPeer.Id} → {msg}"); + + // 클라이언트 ReceivedCount 증가시키려면 응답 필요 + var writer = new NetDataWriter(); + writer.Put($"ack:{msg}"); + fromPeer.Send(writer, deliveryMethod); + + reader.Recycle(); +}; + +server.Start(PORT); +Console.WriteLine($"[SERVER] Started on port {PORT}. Press Enter to stop."); + +while (!Console.KeyAvailable) +{ + server.PollEvents(); + Thread.Sleep(15); +} + +server.Stop(); +Console.WriteLine("[SERVER] Stopped."); \ No newline at end of file diff --git a/ClientTester/TempServer/TempServer.csproj b/ClientTester/TempServer/TempServer.csproj new file mode 100644 index 0000000..401b1b5 --- /dev/null +++ b/ClientTester/TempServer/TempServer.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/MMOTestServer/.dockerignore b/MMOTestServer/.dockerignore new file mode 100644 index 0000000..d42b569 --- /dev/null +++ b/MMOTestServer/.dockerignore @@ -0,0 +1,26 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +*.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/MMOTestServer/.editorconfig b/MMOTestServer/.editorconfig new file mode 100644 index 0000000..2db3adc --- /dev/null +++ b/MMOTestServer/.editorconfig @@ -0,0 +1,362 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 +max_line_length = 140 + +[*.cs] +# ------------------------------------------------------------------------- +# 중괄호 Allman 스타일 +# ------------------------------------------------------------------------- +# Good: Bad: +# if (isAlive) if (isAlive) { +# { TakeDamage(); +# TakeDamage(); } +# } +csharp_new_line_before_open_brace = all + +# ------------------------------------------------------------------------- +# 중괄호 항상 사용 +# ------------------------------------------------------------------------- +# Good: Bad: +# if (isAlive) if (isAlive) +# { TakeDamage(); +# TakeDamage(); +# } +csharp_prefer_braces = true:warning + +# ------------------------------------------------------------------------- +# 접근 제한자 항상 명시 +# ------------------------------------------------------------------------- +# Good: Bad: +# private int health; int health; +# public float moveSpeed; float moveSpeed; +dotnet_style_require_accessibility_modifiers = always:warning + +# ------------------------------------------------------------------------- +# this. 한정자 허용 (필드와 매개변수 이름이 같을 때 필요) +# ------------------------------------------------------------------------- +# 허용: this.stateContext = stateContext; (매개변수와 필드명 동일 시) +# 불필요: this.Initialize(); (모호하지 않은 경우) +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# ------------------------------------------------------------------------- +# var 사용 금지 +# ------------------------------------------------------------------------- +# Good: Bad: +# Enemy enemy = GetEnemy(); var enemy = GetEnemy(); +# int count = 0; var count = 0; +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = false:warning +csharp_style_var_elsewhere = false:warning + +# ------------------------------------------------------------------------- +# null 체크 스타일 강제 +# ------------------------------------------------------------------------- +# Good: Bad: +# animator?.Play("Run"); if (animator != null) animator.Play("Run"); +# string name = playerName ?? "Unknown"; string name = playerName != null ? playerName : "Unknown"; +# if (obj is null) { } if (object.ReferenceEquals(obj, null)) { } +csharp_style_conditional_delegate_call = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_coalesce_expression = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning + +# ------------------------------------------------------------------------- +# 패턴 매칭 강제 (as + null 체크 대신 is 패턴 사용) +# ------------------------------------------------------------------------- +# Good: Bad: +# if (obj is Enemy enemy) Enemy enemy = obj as Enemy; +# { if (enemy != null) +# enemy.TakeDamage(); { +# } enemy.TakeDamage(); +# } +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +# not 패턴 (C# 9+) +# Good: if (obj is not null) Bad: if (!(obj is null)) +# Good: if (enemy is not Dead) Bad: if (!(enemy is Dead)) +csharp_style_prefer_not_pattern = true:warning + +# ------------------------------------------------------------------------- +# throw 표현식 강제 +# ------------------------------------------------------------------------- +# Good: +# target = player ?? throw new ArgumentNullException(nameof(player)); +# +# Bad: +# if (player == null) throw new ArgumentNullException(nameof(player)); +# target = player; +csharp_style_throw_expression = true:warning + +# ------------------------------------------------------------------------- +# 인라인 변수 선언 강제 +# ------------------------------------------------------------------------- +# Good: Bad: +# TryGetComponent(out Rigidbody rb); Rigidbody rb; +# TryGetComponent(out rb); +csharp_style_inlined_variable_declaration = true:warning + +# ------------------------------------------------------------------------- +# 튜플 해체 선언 +# ------------------------------------------------------------------------- +# Good: Bad: +# var (x, y) = GetPosition(); var pos = GetPosition(); +# var x = pos.x; var y = pos.y; +csharp_style_deconstructed_variable_declaration = true:suggestion + +# ------------------------------------------------------------------------- +# 간단한 using 선언문 +# ------------------------------------------------------------------------- +# Good: Bad: +# using var stream = File.Open(...); using (var stream = File.Open(...)) +# { +# } +csharp_prefer_simple_using_statement = true:suggestion + +# ------------------------------------------------------------------------- +# 인덱스 / 범위 연산자 (C# 8+) +# ------------------------------------------------------------------------- +# Good: items[^1] Bad: items[items.Length - 1] +# Good: items[1..3] Bad: items.Skip(1).Take(2) +csharp_style_prefer_index_from_end = true:suggestion +csharp_style_prefer_range_operator = true:suggestion + +# ------------------------------------------------------------------------- +# switch 표현식 권장 +# ------------------------------------------------------------------------- +# Good: Bad: +# string label = state switch string label; +# { switch (state) +# GameState.Playing => "Playing", { +# GameState.Paused => "Paused", case GameState.Playing: label = "Playing"; break; +# _ => "Unknown" case GameState.Paused: label = "Paused"; break; +# }; default: label = "Unknown"; break; +# } +csharp_style_prefer_switch_expression = true:suggestion + +# ------------------------------------------------------------------------- +# 불필요한 코드 제거 +# ------------------------------------------------------------------------- +# object initializer +# Good: Bad: +# Enemy enemy = new Enemy Enemy enemy = new Enemy(); +# { enemy.hp = 100; +# hp = 100, enemy.name = "Goblin"; +# name = "Goblin" +# }; +# +# 미사용 반환값은 _ 로 명시적으로 버리기 +# Good: Bad: +# _ = TryGetComponent(out Rigidbody rb); TryGetComponent(out Rigidbody rb); +dotnet_style_object_initializer = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_remove_unnecessary_suppression_exclusions = true +csharp_style_unused_value_assignment_preference = discard_variable:warning +csharp_style_unused_value_expression_statement_preference = discard_variable:warning + +# ------------------------------------------------------------------------- +# 단순화 강제 +# ------------------------------------------------------------------------- +# auto property +# Good: Bad: +# public int Hp { get; private set; } private int hp; +# public int Hp { get { return hp; } } +# +# boolean 단순화 +# Good: Bad: +# return isAlive; return isAlive == true; +# +# 삼항 연산자 (suggestion - 복잡한 경우 강제 안 함) +# Good: Bad: +# int damage = isCrit ? 200 : 100; int damage; +# if (isCrit) damage = 200; +# else damage = 100; +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_simplified_boolean_expressions = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion + +# ------------------------------------------------------------------------- +# 복합 할당 연산자 +# ------------------------------------------------------------------------- +# Good: hp -= damage; Bad: hp = hp - damage; +# Good: score += point; Bad: score = score + point; +dotnet_style_prefer_compound_assignment = true:warning + +# ------------------------------------------------------------------------- +# 타입 예약어 강제 (BCL 타입명 대신 C# 예약어 사용) +# ------------------------------------------------------------------------- +# Good: Bad: +# int count = 0; Int32 count = 0; +# string name = "Player"; String name = "Player"; +# object obj = new Enemy(); Object obj = new Enemy(); +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# ------------------------------------------------------------------------- +# expression-bodied 프로퍼티/접근자 강제, 메서드/생성자/로컬함수 금지 +# ------------------------------------------------------------------------- +# Good: Bad: +# public int CurrentHp => currentHp; public int CurrentHp +# { +# get { return currentHp; } +# } +# +# 메서드 / 생성자 / 로컬 함수는 expression-bodied 금지 +# Good: Bad: +# public void TakeDamage(int damage) public void TakeDamage(int damage) => hp -= damage; +# { +# hp -= damage; +# } +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_methods = false:warning +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_local_functions = false:warning + +# ------------------------------------------------------------------------- +# 한 줄 블록 금지 (포맷터 적용 시 자동 정리) +# ------------------------------------------------------------------------- +# Good: Bad: +# if (isAlive) if (isAlive) { TakeDamage(); } +# { +# TakeDamage(); +# } +# 주의: 이 설정은 경고가 아닌 포맷터 실행 시에만 적용됩니다. +csharp_preserve_single_line_blocks = false +csharp_preserve_single_line_statements = false + +# ------------------------------------------------------------------------- +# using 정렬 +# ------------------------------------------------------------------------- +# Good: +# using System.Collections; +# using System.Collections.Generic; +# using UnityEngine; +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace:warning +# 네임스페이스는 폴더 구조와 일치 +# Good: Assets/Scripts/Player/ → namespace Project.Player +dotnet_style_namespace_match_folder = true:suggestion + +# ------------------------------------------------------------------------- +# 네이밍 규칙 +# ------------------------------------------------------------------------- +# 주의: 더 구체적인 규칙(const 등)을 먼저 선언해야 올바르게 적용됩니다. + +# 상수: UPPER_SNAKE_CASE (const modifier로 가장 구체적 → 최우선) +# Good: const int MAX_LEVEL = 100; Bad: const int MaxLevel = 100; +# Good: const float DEFAULT_SPEED = 5f; Bad: const float defaultSpeed = 5f; +dotnet_naming_rule.constants.severity = warning +dotnet_naming_rule.constants.symbols = constants +dotnet_naming_rule.constants.style = upper_snake_case + +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.upper_snake_case.capitalization = all_upper +dotnet_naming_style.upper_snake_case.word_separator = _ + +# private / protected 필드: camelCase (Unity 스타일 통일) +# Good: private int currentHp; Bad: private int _currentHp; +# Good: protected float baseSpeed; Bad: protected float _baseSpeed; +dotnet_naming_rule.private_fields.severity = warning +dotnet_naming_rule.private_fields.symbols = private_fields +dotnet_naming_rule.private_fields.style = camel_case_style + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal + +# public 필드: camelCase (Unity Inspector 노출 필드 기준 - 일반 C# 컨벤션과 다름) +# Good: public float moveSpeed; Bad: public float MoveSpeed; +# Good: public int maxHealth; Bad: public int MaxHealth; +dotnet_naming_rule.public_fields.severity = warning +dotnet_naming_rule.public_fields.symbols = public_fields +dotnet_naming_rule.public_fields.style = camel_case_style + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# 인터페이스: IPascalCase +# Good: interface IEnemy { } Bad: interface Enemy { } +# Good: interface IDamageable { } Bad: interface Damageable { } +dotnet_naming_rule.interfaces.severity = warning +dotnet_naming_rule.interfaces.symbols = interfaces +dotnet_naming_rule.interfaces.style = prefix_i + +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +dotnet_naming_style.prefix_i.capitalization = pascal_case +dotnet_naming_style.prefix_i.required_prefix = I + +# 클래스 / 구조체 / 열거형: PascalCase +# Good: class PlayerController { } Bad: class playerController { } +# Good: enum GameState { } Bad: enum gameState { } +dotnet_naming_rule.types.severity = warning +dotnet_naming_rule.types.symbols = types +dotnet_naming_rule.types.style = pascal_case_style + +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# 메서드 / 프로퍼티 / 이벤트: PascalCase +# Good: public void TakeDamage() { } Bad: public void takeDamage() { } +# Good: public int CurrentHp { } Bad: public int currentHp { } +# Good: public event Action OnDeath; Bad: public event Action onDeath; +dotnet_naming_rule.members.severity = warning +dotnet_naming_rule.members.symbols = members +dotnet_naming_rule.members.style = pascal_case_style + +dotnet_naming_symbols.members.applicable_kinds = method, property, event + +# 매개변수 / 로컬 변수: camelCase +# Good: void Init(int maxHp) { } Bad: void Init(int MaxHp) { } +# Good: float moveSpeed = 5f; Bad: float MoveSpeed = 5f; +dotnet_naming_rule.parameters_and_locals.severity = warning +dotnet_naming_rule.parameters_and_locals.symbols = parameters_and_locals +dotnet_naming_rule.parameters_and_locals.style = camel_case_style + +dotnet_naming_symbols.parameters_and_locals.applicable_kinds = parameter, local + +# enum 멤버: PascalCase +# Good: GameState.Playing Bad: GameState.playing +# Good: GameState.GameOver Bad: GameState.GAME_OVER +dotnet_naming_rule.enum_members.severity = warning +dotnet_naming_rule.enum_members.symbols = enum_members +dotnet_naming_rule.enum_members.style = pascal_case_style + +dotnet_naming_symbols.enum_members.applicable_kinds = enum_member + +[*.{json,yaml,yml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{asmdef,asmref}] +indent_size = 4 + +[*.shader] +indent_size = 4 + +[*.{compute,hlsl,cginc}] +indent_size = 4 + +[*.{uss,uxml}] +indent_size = 4 diff --git a/MMOTestServer/MMOserver.sln b/MMOTestServer/MMOserver.sln new file mode 100644 index 0000000..67e8655 --- /dev/null +++ b/MMOTestServer/MMOserver.sln @@ -0,0 +1,27 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MMOserver", "MMOserver\MMOserver.csproj", "{A1676221-8C43-4BA8-89B9-B5FD668632E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{1F979F90-C11E-4BA5-B226-76DC1C052640}" + ProjectSection(SolutionItems) = preProject + compose.yaml = compose.yaml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServerLib", "ServerLib\ServerLib.csproj", "{7BE5A9C9-D4E2-4C51-8E45-972F487D2637}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1676221-8C43-4BA8-89B9-B5FD668632E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1676221-8C43-4BA8-89B9-B5FD668632E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1676221-8C43-4BA8-89B9-B5FD668632E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1676221-8C43-4BA8-89B9-B5FD668632E8}.Release|Any CPU.Build.0 = Release|Any CPU + {7BE5A9C9-D4E2-4C51-8E45-972F487D2637}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BE5A9C9-D4E2-4C51-8E45-972F487D2637}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BE5A9C9-D4E2-4C51-8E45-972F487D2637}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BE5A9C9-D4E2-4C51-8E45-972F487D2637}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/MMOTestServer/MMOserver/Dockerfile b/MMOTestServer/MMOserver/Dockerfile new file mode 100644 index 0000000..15ae802 --- /dev/null +++ b/MMOTestServer/MMOserver/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["MMOserver/MMOserver.csproj", "MMOserver/"] +RUN dotnet restore "MMOserver/MMOserver.csproj" +COPY . . +WORKDIR "/src/MMOserver" +RUN dotnet build "./MMOserver.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./MMOserver.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# UDP 9050 포트 열기 +EXPOSE 9050/udp + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "MMOserver.dll"] diff --git a/MMOTestServer/MMOserver/Game/GameServer.cs b/MMOTestServer/MMOserver/Game/GameServer.cs new file mode 100644 index 0000000..c482f46 --- /dev/null +++ b/MMOTestServer/MMOserver/Game/GameServer.cs @@ -0,0 +1,28 @@ +using LiteNetLib; +using Serilog; +using ServerLib.Service; + +namespace MMOserver.Game; + +public class GameServer : ServerBase +{ + public GameServer(int port, string connectionString) : base(port, connectionString) + { + + } + + protected override void OnSessionConnected(NetPeer peer, long hashKey) + { + throw new NotImplementedException(); + } + + protected override void OnSessionDisconnected(NetPeer peer, long hashKey, DisconnectInfo info) + { + throw new NotImplementedException(); + } + + protected override void HandlePacket(NetPeer peer, long hashKey, ushort type, byte[] payload) + { + throw new NotImplementedException(); + } +} diff --git a/MMOTestServer/MMOserver/MMOserver.csproj b/MMOTestServer/MMOserver/MMOserver.csproj new file mode 100644 index 0000000..4fde807 --- /dev/null +++ b/MMOTestServer/MMOserver/MMOserver.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + enable + enable + Linux + + + + + + + .dockerignore + + + + + + + + diff --git a/MMOTestServer/MMOserver/Packet/PacketBody.cs b/MMOTestServer/MMOserver/Packet/PacketBody.cs new file mode 100644 index 0000000..a3fbcc1 --- /dev/null +++ b/MMOTestServer/MMOserver/Packet/PacketBody.cs @@ -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 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 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 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; + } +} diff --git a/MMOTestServer/MMOserver/Packet/PacketHeader.cs b/MMOTestServer/MMOserver/Packet/PacketHeader.cs new file mode 100644 index 0000000..b95aa68 --- /dev/null +++ b/MMOTestServer/MMOserver/Packet/PacketHeader.cs @@ -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; +} \ No newline at end of file diff --git a/MMOTestServer/MMOserver/Program.cs b/MMOTestServer/MMOserver/Program.cs new file mode 100644 index 0000000..261bbe4 --- /dev/null +++ b/MMOTestServer/MMOserver/Program.cs @@ -0,0 +1,46 @@ +using MMOserver.Game; +using Serilog; + +class Program +{ + private static void Main() + { + string timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .WriteTo.File($"logs/log_{timestamp}.txt") + .CreateLogger(); + + Log.Information("Write Log Started"); + + int port = 9500; + string connectionString = "test"; + GameServer gameServer = new GameServer(port, connectionString); + + // Ctrl+C → Stop() 호출 → Run() 루프 탈출 → serverThread 종료 + Console.CancelKeyPress += (_, e) => + { + // 프로세스 즉시 종료 막기 + e.Cancel = true; + gameServer.Stop(); + }; + + // 게임 서버 스레드 생성 + Thread serverThread = new Thread(() => + { + gameServer.Init(); + gameServer.Run(); + }); + + // 게임 서버 스레드 시작 + serverThread.Start(); + + // Run()이 끝날 때까지 대기 + serverThread.Join(); + + // Log 종료 + Log.CloseAndFlush(); + } +} diff --git a/MMOTestServer/ServerLib/Packet/PacketSerializer.cs b/MMOTestServer/ServerLib/Packet/PacketSerializer.cs new file mode 100644 index 0000000..1feebbb --- /dev/null +++ b/MMOTestServer/ServerLib/Packet/PacketSerializer.cs @@ -0,0 +1,53 @@ +using ProtoBuf; +using Serilog; + +namespace ServerLib.Packet +{ + // 패킷 헤더 크기 4(패킷 타입, 패킷 길이) + + public static class PacketSerializer + { + // 직렬화: 객체 → byte[] + public static byte[] Serialize(ushort type, ushort size, T packet) + { + MemoryStream ms = new MemoryStream(); + + // 2바이트 패킷 타입 헤더 + ms.WriteByte((byte)(type & 0xFF)); + ms.WriteByte((byte)(type >> 8)); + + // 2바이트 패킷 길이 헤더 + ms.WriteByte((byte)(size & 0xFF)); + ms.WriteByte((byte)(size >> 8)); + + // protobuf 페이로드 + Serializer.Serialize(ms, packet); + return ms.ToArray(); + } + + // 역직렬화: byte[] → (PacketType, payload bytes) + public static (ushort type, ushort size, byte[] payload) Deserialize(byte[] data) + { + if (data.Length < 4) + { + Log.Warning("[PacketHeader]의 길이가 4이하입니다."); + return (0, 0, null)!; + } + + // 길이체크도 필요함 + + ushort type = (ushort)(data[0] | (data[1] << 8)); + ushort size = (ushort)(data[2] | (data[3] << 8)); + byte[] payload = new byte[data.Length - 4]; + Buffer.BlockCopy(data, 4, payload, 0, payload.Length); + return (type, size, payload); + } + + // 페이로드 → 특정 타입으로 역직렬화 + public static T DeserializePayload(byte[] payload) + { + MemoryStream ms = new MemoryStream(payload); + return Serializer.Deserialize(ms); + } + } +} diff --git a/MMOTestServer/ServerLib/Packet/PacketType.cs b/MMOTestServer/ServerLib/Packet/PacketType.cs new file mode 100644 index 0000000..5aaece0 --- /dev/null +++ b/MMOTestServer/ServerLib/Packet/PacketType.cs @@ -0,0 +1,11 @@ +namespace ServerLib.Packet; + +/// +/// 패킷 타입 식별자 (2바이트 헤더) +/// Auth는 베이스에서 처리, 게임 패킷은 하위 프로젝트에서 추가 정의 +/// +public enum PacketType : ushort +{ + Auth = 1, // 클라 → 서버: 최초 인증 (HashKey 전달) + // 1000번 이상은 게임 패킷으로 예약 +} diff --git a/MMOTestServer/ServerLib/ServerLib.csproj b/MMOTestServer/ServerLib/ServerLib.csproj new file mode 100644 index 0000000..38c79cd --- /dev/null +++ b/MMOTestServer/ServerLib/ServerLib.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/MMOTestServer/ServerLib/Service/ServerBase.cs b/MMOTestServer/ServerLib/Service/ServerBase.cs new file mode 100644 index 0000000..81bcdc1 --- /dev/null +++ b/MMOTestServer/ServerLib/Service/ServerBase.cs @@ -0,0 +1,239 @@ +using System.Net; +using System.Net.Sockets; +using LiteNetLib; +using LiteNetLib.Utils; +using Serilog; +using ServerLib.Packet; + +namespace ServerLib.Service; + +/// +/// 네트워킹 추상 베이스 (protobuf 없음) +/// +/// 흐름: +/// OnPeerConnected → 대기 목록 등록 +/// OnNetworkReceive → Auth 패킷(type=1)이면 HashKey(8byte long) 읽어 인증 +/// → 이미 같은 HashKey 세션 있으면 이전 피어 끊고 재연결 (WiFi→LTE) +/// → 그 외 패킷은 HandlePacket() 으로 전달 +/// OnPeerDisconnected → 세션/대기 목록에서 제거 +/// +/// 서브클래스 구현: +/// OnSessionConnected - 인증 완료 시 +/// OnSessionDisconnected - 세션 정상 해제 시 (재연결 교체는 호출 안 함) +/// HandlePacket - 인증된 피어의 게임 패킷 처리 +/// +public abstract class ServerBase : INetEventListener +{ + protected NetManager netManager = null!; + + // 인증 전 대기 피어 (peer.Id → NetPeer) + private readonly Dictionary pendingPeers = new(); + + // 인증된 세션 (hashKey → NetPeer) 재연결 조회용 + // peer → hashKey 역방향은 peer.Tag as Session 으로 대체 + private readonly Dictionary sessions = new(); + + // 핑 로그 출력 여부 + public bool PingLogRtt { get; set; } + + public int Port { get; } + public string ConnectionString { get; } + + private bool isListening = false; + + public ServerBase(int port, string connectionString) + { + Port = port; + ConnectionString = connectionString; + } + + public virtual void Init(int pingInterval = 3000, int disconnectTimeout = 60000) + { + netManager = new NetManager(this) + { + AutoRecycle = true, + PingInterval = pingInterval, + DisconnectTimeout = disconnectTimeout, + }; + isListening = true; + } + + public virtual void Run() + { + netManager.Start(Port); + Log.Information("[Server] 시작 Port={Port}", Port); + + while (isListening) + { + netManager.PollEvents(); + Thread.Sleep(15); + } + netManager.Stop(); + Log.Information("[Server] 종료 Port={Port}", Port); + } + + public void Stop() + { + isListening = false; + } + + // 클라이언트 연결 요청 수신 → Accept / Reject 결정 + public void OnConnectionRequest(ConnectionRequest request) + { + // 벤 기능 추가? 한국 ip만? + if (request.AcceptIfKey(ConnectionString) == null) + { + Log.Debug("해당 클라이언트의 ConnectionKey={request.ConnectionKey}가 동일하지 않습니다", request.Data.ToString()); + } + } + + // 클라이언트가 연결 완료됐을 때 호출 + public void OnPeerConnected(NetPeer peer) + { + pendingPeers[peer.Id] = peer; + Log.Debug("[Server] 대기 등록 PeerId={Id} IP={IP}", peer.Id, peer.Address); + } + + // 클라이언트가 연결 해제됐을 때 (타임아웃, 명시적 끊기 등) + public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) + { + pendingPeers.Remove(peer.Id); + + if (peer.Tag is Session session) + { + // 현재 인증된 피어가 이 peer일 때만 세션 제거 + // (재연결로 이미 교체된 경우엔 건드리지 않음) + if (sessions.TryGetValue(session.HashKey, out NetPeer? current) && current.Id == peer.Id) + { + sessions.Remove(session.HashKey); + Log.Information("[Server] 세션 해제 HashKey={Key} Reason={Reason}", session.HashKey, disconnectInfo.Reason); + OnSessionDisconnected(peer, session.HashKey, disconnectInfo); + } + peer.Tag = null; + } + } + + // 연결된 피어로부터 데이터 수신 시 핵심 콜백 + public void OnNetworkReceive(NetPeer peer, NetPacketReader reader, byte channelNumber, DeliveryMethod deliveryMethod) + { + byte[] data = reader.GetRemainingBytes(); + + try + { + (ushort type, ushort size, byte[] payload) = PacketSerializer.Deserialize(data); + + // Auth 패킷은 베이스에서 처리 (raw 8-byte long, protobuf 불필요) + if (type == (ushort)PacketType.Auth) + { + HandleAuth(peer, payload); + return; + } + + // 인증된 피어인지 확인 + if (peer.Tag is not Session session) + { + // 추가로 벤 때려도 될듯 + Log.Warning("[Server] 미인증 패킷 무시 PeerId={Id} Type={Type}", peer.Id, type); + return; + } + + HandlePacket(peer, session.HashKey, type, payload); + } + catch (Exception ex) + { + Log.Error(ex, "[Server] 패킷 처리 오류 PeerId={Id}", peer.Id); + } + } + + // 소켓 레벨 오류 발생 시 + public void OnNetworkError(IPEndPoint endPoint, SocketError socketError) + { + Log.Error("[Server] 네트워크 오류 {EP} {Err}", endPoint, socketError); + } + + // 미연결 상태의 UDP 메시지 수신 (LAN 탐색, 브로드캐스트 등) + public void OnNetworkReceiveUnconnected(IPEndPoint endPoint, NetPacketReader reader, UnconnectedMessageType messageType) + { + // 혹시나 외부에서 이벤트 발생관련 수신이라면 여기에 구현? 경험치 배율 이런거 + Log.Warning("[Server] 미연결 패킷 수신 {EP} 무시", endPoint); + } + + // 핑 갱신 시 (ms) + public void OnNetworkLatencyUpdate(NetPeer peer, int latency) + { + if (PingLogRtt) + { + // rtt 시간 출력 + } + } + + // ─── Auth 처리 (내부) ──────────────────────────────────────────────── + + private void HandleAuth(NetPeer peer, byte[] payload) + { + if (payload.Length < sizeof(long)) + { + Log.Warning("[Server] Auth 페이로드 크기 오류 PeerId={Id}", peer.Id); + peer.Disconnect(); + return; + } + + long hashKey = BitConverter.ToInt64(payload, 0); + + if (sessions.TryGetValue(hashKey, out NetPeer? existing)) + { + // WiFi → LTE 전환 등 재연결: 이전 피어 교체 + existing.Tag = null; + sessions.Remove(hashKey); + Log.Information("[Server] 재연결 HashKey={Key} Old={Old} New={New}", hashKey, existing.Id, peer.Id); + existing.Disconnect(); + } + + peer.Tag = new Session(hashKey, peer); + sessions[hashKey] = peer; + pendingPeers.Remove(peer.Id); + + Log.Information("[Server] 인증 완료 HashKey={Key} PeerId={Id}", hashKey, peer.Id); + OnSessionConnected(peer, hashKey); + } + + // ─── 전송 헬퍼 ─────────────────────────────────────────────────────── + + // NetDataWriter writer 풀처리 필요할듯 + // peer에게 전송 + protected void SendTo(NetPeer peer, byte[] data, DeliveryMethod method = DeliveryMethod.ReliableOrdered) + { + NetDataWriter writer = new NetDataWriter(); + writer.Put(data); + peer.Send(writer, method); + } + + // 모두에게 전송 + protected void Broadcast(byte[] data, DeliveryMethod method = DeliveryMethod.ReliableOrdered) + { + // 일단 channelNumber는 건드리지 않는다 + NetDataWriter writer = new NetDataWriter(); + writer.Put(data); + netManager.SendToAll(writer, 0, method); + } + + // exclude 1개 제외 / 나 제외 정도 + protected void BroadcastExcept(byte[] data, NetPeer exclude, DeliveryMethod method = DeliveryMethod.ReliableOrdered) + { + // 일단 channelNumber는 건드리지 않는다 + NetDataWriter writer = new NetDataWriter(); + writer.Put(data); + netManager.SendToAll(writer, 0, method, exclude); + } + + // ─── 서브클래스 구현 ───────────────────────────────────────────────── + + // 인증(Auth) 완료 후 호출 + protected abstract void OnSessionConnected(NetPeer peer, long hashKey); + + // 세션 정상 해제 시 호출 (재연결 교체 시에는 호출되지 않음) + protected abstract void OnSessionDisconnected(NetPeer peer, long hashKey, DisconnectInfo info); + + // 인증된 피어의 게임 패킷 수신 / payload는 헤더 제거된 raw bytes → 실행 프로젝트에서 protobuf 역직렬화 + protected abstract void HandlePacket(NetPeer peer, long hashKey, ushort type, byte[] payload); +} diff --git a/MMOTestServer/ServerLib/Service/Session.cs b/MMOTestServer/ServerLib/Service/Session.cs new file mode 100644 index 0000000..892c3a6 --- /dev/null +++ b/MMOTestServer/ServerLib/Service/Session.cs @@ -0,0 +1,15 @@ +using LiteNetLib; + +namespace ServerLib.Service; + +public class Session +{ + public long HashKey { get; init; } + public NetPeer Peer { get; set; } + + public Session(long hashKey, NetPeer peer) + { + HashKey = hashKey; + Peer = peer; + } +} diff --git a/MMOTestServer/ServerLib/Service/SessionManager.cs b/MMOTestServer/ServerLib/Service/SessionManager.cs new file mode 100644 index 0000000..49cb4a8 --- /dev/null +++ b/MMOTestServer/ServerLib/Service/SessionManager.cs @@ -0,0 +1,24 @@ +namespace ServerLib.Service; + +public class SessionManager +{ + public Dictionary Sessions + { + get; + } + + public SessionManager() + { + Sessions = new Dictionary(); + } + + public void AddSession(Session session) + { + Sessions.Add(session.GetHashCode(), session); + } + + public void RemoveSession(Session session) + { + Sessions.Remove(session.GetHashCode()); + } +} diff --git a/MMOTestServer/compose.yaml b/MMOTestServer/compose.yaml new file mode 100644 index 0000000..e7fc61d --- /dev/null +++ b/MMOTestServer/compose.yaml @@ -0,0 +1,9 @@ +services: + mmoserver: + image: mmoserver + build: + context: . + dockerfile: MMOserver/Dockerfile + ports: + - "9050:9050/udp" # LiteNetLib UDP 포트 + restart: unless-stopped diff --git a/MMOTestServer/global.json b/MMOTestServer/global.json new file mode 100644 index 0000000..93681ff --- /dev/null +++ b/MMOTestServer/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file