diff --git a/.clinerules b/.clinerules
new file mode 100644
index 0000000..4cf8a4a
--- /dev/null
+++ b/.clinerules
@@ -0,0 +1,6 @@
+Use `AGENTS.md` as the canonical repository guide.
+
+Repository-specific constraints:
+- Packet size header (`Defines.HEADERSIZE`) and `CPacket` push/pop order are compatibility-critical.
+- `IPeer` binding (`token.set_peer(this)`) and session lifecycle callbacks must be preserved.
+- Threading behavior depends on `CNetworkService(use_logicthread)`; validate changes against the selected mode.
diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 0000000..8bdf3ab
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,6 @@
+Follow `AGENTS.md` for repository-specific guidance.
+
+Priorities:
+1. Keep FreeNet packet protocol/framing behavior unchanged unless explicitly requested.
+2. Maintain compatibility between `FreeNet/`, `CSampleServer/`, `CSampleClient/`, and `viruswar/server/GameServer/`.
+3. Use existing build/run commands from `AGENTS.md`; no new toolchain assumptions.
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1499da4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,385 @@
+root = true
+
+# All files
+[*]
+indent_style = space
+
+# Xml files
+[*.xml]
+indent_size = 2
+
+# Xml project files
+[*.{csproj,fsproj,vbproj,proj,slnx}]
+indent_size = 2
+
+# Xml config files
+[*.{props,targets,config,nuspec}]
+indent_size = 2
+
+[*.json]
+indent_size = 2
+
+# C# files
+[*.cs]
+#### Core EditorConfig Options ####
+
+# Indentation and spacing
+indent_size = 4
+tab_width = 4
+
+# New line preferences
+insert_final_newline = true
+
+#### .NET Coding Conventions ####
+[*.{cs,vb}]
+# Organize usings
+file_header_template = unset
+dotnet_separate_import_directive_groups = false
+dotnet_sort_system_directives_first = false
+
+# this. and Me. preferences
+dotnet_style_qualification_for_event = false:silent
+dotnet_style_qualification_for_field = false:silent
+dotnet_style_qualification_for_method = false:silent
+dotnet_style_qualification_for_property = false:silent
+
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_predefined_type_for_member_access = true:silent
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+
+# Expression-level preferences
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_namespace_match_folder = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+
+# Field preferences
+dotnet_style_readonly_field = true:warning
+
+# Parameter preferences
+dotnet_code_quality_unused_parameters = all:suggestion
+
+# Suppression preferences
+dotnet_remove_unnecessary_suppression_exclusions = none
+
+#### C# Coding Conventions ####
+[*.cs]
+# var preferences
+csharp_style_var_elsewhere = true:suggestion
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+
+# Expression-bodied members
+csharp_style_expression_bodied_accessors = true:suggestion
+csharp_style_expression_bodied_constructors = true:suggestion
+csharp_style_expression_bodied_indexers = true:suggestion
+csharp_style_expression_bodied_lambdas = true:suggestion
+csharp_style_expression_bodied_local_functions = true:suggestion
+csharp_style_expression_bodied_methods = true:suggestion
+csharp_style_expression_bodied_operators = true:suggestion
+csharp_style_expression_bodied_properties = true:suggestion
+
+# Pattern matching preferences
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_prefer_extended_property_pattern = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_pattern_matching = true:suggestion
+csharp_style_prefer_switch_expression = true:suggestion
+
+# Null-checking preferences
+csharp_style_conditional_delegate_call = true:suggestion
+
+# Modifier preferences
+csharp_prefer_static_anonymous_function = true:suggestion
+csharp_prefer_static_local_function = true:warning
+csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
+csharp_style_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+
+# Code-block preferences
+csharp_prefer_braces = true:suggestion
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_style_namespace_declarations = file_scoped:suggestion
+csharp_style_prefer_method_group_conversion = true:suggestion
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_style_prefer_top_level_statements = true:suggestion
+
+# Expression-level preferences
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:suggestion
+
+# 'using' directive preferences
+csharp_using_directive_placement = outside_namespace:suggestion
+
+#### C# Formatting Rules ####
+
+# New line preferences
+csharp_new_line_before_catch = true
+csharp_new_line_before_else = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_open_brace = all
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation preferences
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_labels = one_less_than_current
+csharp_indent_switch_labels = true
+
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_after_comma = true
+csharp_space_after_dot = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_around_declaration_statements = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_before_comma = false
+csharp_space_before_dot = false
+csharp_space_before_open_square_brackets = false
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+csharp_space_between_square_brackets = false
+
+# Wrapping preferences
+csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = true
+
+#### Naming styles ####
+[*.{cs,vb}]
+# Naming rules
+
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
+
+dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
+dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
+dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
+
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
+
+dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
+
+dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
+
+dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.events_should_be_pascalcase.symbols = events
+
+dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
+dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
+
+dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
+dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
+
+dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
+dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
+
+dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
+
+dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
+dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
+dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
+
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
+
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
+
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
+
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
+
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
+
+dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
+
+dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
+
+dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
+dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
+
+# Symbol specifications
+
+dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interfaces.applicable_kinds = interface
+dotnet_naming_symbols.interfaces.required_modifiers =
+
+dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.enums.applicable_kinds = enum
+dotnet_naming_symbols.enums.required_modifiers =
+
+dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.events.applicable_kinds = event
+dotnet_naming_symbols.events.required_modifiers =
+
+dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.methods.applicable_kinds = method
+dotnet_naming_symbols.methods.required_modifiers =
+
+dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.properties.applicable_kinds = property
+dotnet_naming_symbols.properties.required_modifiers =
+
+dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_fields.applicable_kinds = field
+dotnet_naming_symbols.public_fields.required_modifiers =
+
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.required_modifiers =
+
+dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_static_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_fields.required_modifiers = static
+
+dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
+dotnet_naming_symbols.types_and_namespaces.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
+dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
+dotnet_naming_symbols.type_parameters.required_modifiers =
+
+dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
+dotnet_naming_symbols.private_constant_fields.required_modifiers = const
+
+dotnet_naming_symbols.local_variables.applicable_accessibilities = local
+dotnet_naming_symbols.local_variables.applicable_kinds = local
+dotnet_naming_symbols.local_variables.required_modifiers =
+
+dotnet_naming_symbols.local_constants.applicable_accessibilities = local
+dotnet_naming_symbols.local_constants.applicable_kinds = local
+dotnet_naming_symbols.local_constants.required_modifiers = const
+
+dotnet_naming_symbols.parameters.applicable_accessibilities = *
+dotnet_naming_symbols.parameters.applicable_kinds = parameter
+dotnet_naming_symbols.parameters.required_modifiers =
+
+dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
+dotnet_naming_symbols.public_constant_fields.required_modifiers = const
+
+dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
+
+dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
+
+dotnet_naming_symbols.local_functions.applicable_accessibilities = *
+dotnet_naming_symbols.local_functions.applicable_kinds = local_function
+dotnet_naming_symbols.local_functions.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.pascalcase.capitalization = pascal_case
+dotnet_naming_style.pascalcase.required_prefix =
+dotnet_naming_style.pascalcase.required_suffix =
+dotnet_naming_style.pascalcase.word_separator =
+
+dotnet_naming_style.ipascalcase.capitalization = pascal_case
+dotnet_naming_style.ipascalcase.required_prefix = I
+dotnet_naming_style.ipascalcase.required_suffix =
+dotnet_naming_style.ipascalcase.word_separator =
+
+dotnet_naming_style.tpascalcase.capitalization = pascal_case
+dotnet_naming_style.tpascalcase.required_prefix = T
+dotnet_naming_style.tpascalcase.required_suffix =
+dotnet_naming_style.tpascalcase.word_separator =
+
+dotnet_naming_style._camelcase.capitalization = camel_case
+dotnet_naming_style._camelcase.required_prefix = _
+dotnet_naming_style._camelcase.required_suffix =
+dotnet_naming_style._camelcase.word_separator =
+
+dotnet_naming_style.camelcase.capitalization = camel_case
+dotnet_naming_style.camelcase.required_prefix =
+dotnet_naming_style.camelcase.required_suffix =
+dotnet_naming_style.camelcase.word_separator =
+
+dotnet_naming_style.s_camelcase.capitalization = camel_case
+dotnet_naming_style.s_camelcase.required_prefix = s_
+dotnet_naming_style.s_camelcase.required_suffix =
+dotnet_naming_style.s_camelcase.word_separator =
+
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..e423c39
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,34 @@
+## Summary
+
+Briefly describe the change and why it matters.
+
+## What changed
+
+-
+
+## Why
+
+-
+
+## Testing
+
+- `dotnet build .\FreeNet.slnx -c Debug`
+- `dotnet test .\FreeNet.slnx -c Debug`
+- `dotnet build .\viruswar\server\viruswar_server.slnx -c Debug` (if applicable)
+- `dotnet test .\viruswar\server\viruswar_server.slnx -c Debug` (if applicable)
+
+## Risk
+
+-
+
+## Related
+
+- Issue:
+- PR:
+
+## Checklist
+
+- [ ] I assigned this PR to the current user
+- [ ] I added appropriate labels
+- [ ] I set a milestone if one applies
+- [ ] I verified the relevant build/test commands
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..b9a9966
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,76 @@
+# Copilot instructions for FreeNet
+
+## Build, test, and lint
+
+### Build
+- Restore/build the main solution:
+ - `dotnet restore .\FreeNet.slnx`
+ - `dotnet build .\FreeNet.slnx -c Debug`
+- Build the VirusWar server solution:
+ - `dotnet build .\viruswar\server\viruswar_server.slnx -c Debug`
+
+### Run samples
+- Start sample server:
+ - `dotnet run --project .\CSampleServer\CSampleServer.csproj`
+- Start sample client:
+ - `dotnet run --project .\CSampleClient\CSampleClient.csproj`
+
+### Tests
+- Run all tests:
+ - `dotnet test .\FreeNet.slnx -c Debug`
+- Run VirusWar server tests:
+ - `dotnet test .\viruswar\server\viruswar_server.slnx -c Debug`
+- Run a single test project:
+ - `dotnet test .\FreeNet.Tests\FreeNet.Tests.csproj -c Debug`
+- Test runner is Microsoft Testing Platform (MTP) via `global.json`.
+- Manual/load validation flow is still documented in `README.md` and `TestManual.md` for socket stress scenarios.
+
+### Lint/format
+- No dedicated lint configuration is present in the repository (no `.editorconfig`, no lint scripts, no analyzer config files).
+
+## High-level architecture
+
+- `FreeNet/` is the core networking library (TCP, async receive/send, pooling, packet framing).
+- `CNetworkService` is the main composition root:
+ - owns `SocketAsyncEventArgs` pools and shared receive buffers,
+ - starts `CListener`,
+ - creates `CUserToken` sessions per connection,
+ - wires session lifecycle callbacks and heartbeat checks.
+- `CUserToken` is the per-connection transport/session object:
+ - receives raw bytes and delegates framing to `CMessageResolver`,
+ - manages outbound queue batching via `SocketAsyncEventArgs.BufferList`,
+ - handles system protocols for close/heartbeat.
+- `CMessageResolver` reconstructs complete packets from stream fragments using a fixed 4-byte length header (`Defines.HEADERSIZE = 4`).
+- Message dispatch has two modes selected by `CNetworkService(use_logicthread)`:
+ - `false`: packet handling runs on IO completion threads.
+ - `true`: packets are queued through `CLogicMessageEntry` + `CDoubleBufferingQueue` and processed on one logic thread.
+- `IPeer` is the application-facing session contract; sample/game servers implement it to process protocol messages and cleanup on disconnect.
+- App/sample surfaces:
+ - `CSampleServer/` and `CSampleClient/` are minimal integration examples.
+ - `viruswar/server/GameServer/` is a fuller game-server usage of the library.
+- `documents/*.png` diagrams in README are part of the intended architecture documentation and should stay consistent with networking/dispatch behavior.
+
+## Key conventions in this codebase
+
+- Naming convention is legacy C-style:
+ - classes typically start with `C` (for example `CUserToken`, `CNetworkService`),
+ - interfaces start with `I`,
+ - many methods use lower-case snake/camel hybrids (`on_message`, `on_removed`, `session_created_callback`).
+- Packet protocol contract is strict:
+ - create with `CPacket.create(protocolId)`,
+ - `push(...)` payload fields in order,
+ - call `record_size()` before sending (or rely on helpers that do it immediately before send),
+ - parse with `pop_*` in exactly the same order.
+- Protocol IDs `<= 0` are reserved for system-level behavior (`SYS_CLOSE_REQ`, `SYS_CLOSE_ACK`, heartbeat control) and should not be reused by game/application protocols.
+- `IPeer` implementations are expected to bind themselves in constructors via `token.set_peer(this)`.
+- `session_created_callback` can be invoked concurrently from IO paths; shared state mutations (for example user lists) are expected to be locked.
+- For non-FreeNet test clients, heartbeat may need to be disabled in sample server (`service.disable_heartbeat()`), matching repository README guidance.
+- For quick transport validation, sample server includes an optional echo path in `CSampleServer/CGameUser.cs` (commented toggle).
+- Keep protocol enums and parser usage aligned across client/server projects (`CSampleServer/protocol.cs`, `CSampleClient/protocol.cs`, `viruswar/server/GameServer/protocol.cs`).
+- NuGet package versions are centrally managed via `Directory.Packages.props`; add/update versions there rather than per-project.
+
+## Pull request workflow
+
+- When creating PRs, assign them to the current user/requester.
+- Add the most appropriate labels for the change.
+- Set a milestone when one applies.
diff --git a/.github/mcp-setup.md b/.github/mcp-setup.md
new file mode 100644
index 0000000..01f13a9
--- /dev/null
+++ b/.github/mcp-setup.md
@@ -0,0 +1,19 @@
+# MCP setup for this repository
+
+Use `.mcp.json.example` as a starter configuration for MCP-enabled clients.
+
+## Preference
+
+Use whichever method minimizes token usage first (CLI, MCP, or built-in tools), while preserving correctness. Use MCP when it is the lower-token practical path or when explicitly requested.
+
+## Included servers
+
+- `github`: repository/issue/PR context and automation
+- `filesystem-freenet`: constrained file access rooted to this repository
+
+## Quick setup
+
+1. Copy `.mcp.json.example` to your client's MCP config location or rename to `.mcp.json` if your client reads it from repository root.
+2. Set a token in your client/environment:
+ - `GITHUB_PERSONAL_ACCESS_TOKEN` with repo access as needed.
+3. Start/reload your MCP client.
diff --git a/.github/prompts/manual-smoke-test.prompt.md b/.github/prompts/manual-smoke-test.prompt.md
new file mode 100644
index 0000000..c80a96a
--- /dev/null
+++ b/.github/prompts/manual-smoke-test.prompt.md
@@ -0,0 +1,11 @@
+# Manual smoke test prompt
+
+Run a minimal manual smoke check for the sample client/server path:
+
+1. Start server:
+ - `dotnet run --project .\CSampleServer\CSampleServer.csproj`
+2. Start client in a separate terminal:
+ - `dotnet run --project .\CSampleClient\CSampleClient.csproj`
+3. Send one chat line from client and confirm one `CHAT_MSG_ACK` response.
+
+If the scenario uses a non-FreeNet client lacking heartbeat support, toggle server heartbeat off via the commented `service.disable_heartbeat()` line in `CSampleServer\Program.cs` as documented in `README.md`.
diff --git a/.github/prompts/protocol-change.prompt.md b/.github/prompts/protocol-change.prompt.md
new file mode 100644
index 0000000..a7bb636
--- /dev/null
+++ b/.github/prompts/protocol-change.prompt.md
@@ -0,0 +1,17 @@
+# Protocol-safe change prompt
+
+Make the requested protocol/networking change in this repository while preserving wire compatibility and thread-safety guarantees.
+
+Constraints to respect:
+- Packet framing uses a 4-byte size header (`Defines.HEADERSIZE = 4`).
+- Application protocol IDs must remain `> 0`; values `<= 0` are reserved for system flow (close/heartbeat).
+- `CPacket.push(...)` and `CPacket.pop_*()` order must stay exactly aligned.
+- `IPeer` implementations must continue to bind with `token.set_peer(this)`.
+- Validate both dispatch modes where relevant:
+ - IO-thread path (`new CNetworkService(false)`)
+ - Logic-thread path (`new CNetworkService(true)`)
+
+When touching protocol definitions, update matching enums/handlers in:
+- `CSampleServer\protocol.cs`
+- `CSampleClient\protocol.cs`
+- `viruswar\server\GameServer\protocol.cs` (if applicable to shared behavior)
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
new file mode 100644
index 0000000..4a3bdbf
--- /dev/null
+++ b/.github/workflows/build-test.yml
@@ -0,0 +1,175 @@
+name: build-test
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-test:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: main
+ solution: FreeNet.slnx
+ - name: viruswar
+ solution: viruswar/server/viruswar_server.slnx
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+ cache: true
+ cache-dependency-path: |
+ Directory.Packages.props
+ **/*.csproj
+ **/*.slnx
+
+ - name: Restore ${{ matrix.name }}
+ run: dotnet restore ${{ matrix.solution }}
+
+ - name: Build ${{ matrix.name }}
+ run: dotnet build ${{ matrix.solution }} -c Debug --no-restore
+
+ - name: Test ${{ matrix.name }}
+ run: dotnet test ${{ matrix.solution }} -c Debug --no-build --results-directory ./TestResults/${{ matrix.name }} --coverage --coverage-output coverage.cobertura.xml --coverage-output-format cobertura
+
+ - name: Upload coverage for ${{ matrix.name }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-${{ matrix.name }}
+ path: TestResults/${{ matrix.name }}/coverage.cobertura.xml
+
+ - name: Publish coverage summary for ${{ matrix.name }}
+ run: |
+ {
+ echo "## Coverage (${{ matrix.name }})"
+ echo ""
+ echo "- Artifact: \`coverage-${{ matrix.name }}\`"
+ echo "- Report: \`TestResults/${{ matrix.name }}/coverage.cobertura.xml\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ coverage-comment:
+ runs-on: ubuntu-latest
+ needs: build-test
+ if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
+ permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+ steps:
+ - name: Download coverage artifacts
+ uses: actions/download-artifact@v8
+ with:
+ pattern: coverage-*
+ path: coverage
+
+ - name: Comment coverage summary
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const fs = require('fs');
+ const path = require('path');
+
+ function findCoverageFiles(dir) {
+ const results = [];
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...findCoverageFiles(fullPath));
+ } else if (entry.isFile() && entry.name === 'coverage.cobertura.xml') {
+ results.push(fullPath);
+ }
+ }
+ return results;
+ }
+
+ function readCoverageSummary(filePath) {
+ const xml = fs.readFileSync(filePath, 'utf8');
+ const match = xml.match(/line-rate="([0-9.]+)"/);
+ if (!match) {
+ throw new Error(`Could not find line-rate in ${filePath}`);
+ }
+
+ const lineRate = Number.parseFloat(match[1]);
+ const artifactName = path.basename(path.dirname(filePath));
+ return {
+ filePath,
+ artifactName,
+ lineRate,
+ percent: (lineRate * 100).toFixed(2),
+ };
+ }
+
+ const coverageFiles = findCoverageFiles('coverage');
+ const summaries = coverageFiles.map(readCoverageSummary).sort((a, b) => a.filePath.localeCompare(b.filePath));
+ const lines = summaries.map(summary => `- \`${summary.artifactName}\`: ${summary.percent}%`);
+ const body = [
+ '## Coverage report',
+ '',
+ ...lines,
+ '',
+ `Workflow run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
+ ].join('\n');
+
+ const issue_number = context.payload.pull_request.number;
+ const comments = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number,
+ });
+
+ const marker = '';
+ const existing = comments.data.find(comment => comment.user?.type === 'Bot' && comment.body?.includes(marker));
+
+ const fullBody = `${marker}\n${body}`;
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body: fullBody,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number,
+ body: fullBody,
+ });
+ }
+
+ - name: Publish aggregated coverage summary
+ run: |
+ {
+ echo "## Coverage summary"
+ echo ""
+ echo "| Artifact | Coverage |"
+ echo "| --- | ---: |"
+ for file in coverage/**/coverage.cobertura.xml; do
+ artifact=$(basename "$(dirname "$file")")
+ percent=$(python -c "import re, sys; from pathlib import Path; xml = Path(sys.argv[1]).read_text(encoding='utf-8'); m = re.search(r'line-rate=\"([0-9.]+)\"', xml); print(f'{float(m.group(1)) * 100:.2f}%')" "$file")
+ echo "| $artifact | $percent |"
+ done
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ad020f0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,355 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# JustCode is a .NET coding add-in
+.JustCode
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# ContextKeeper
+.contextkeeper/
diff --git a/.mcp.json.example b/.mcp.json.example
new file mode 100644
index 0000000..36a3efc
--- /dev/null
+++ b/.mcp.json.example
@@ -0,0 +1,22 @@
+{
+ "mcpServers": {
+ "github": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-github"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:GITHUB_PERSONAL_ACCESS_TOKEN}"
+ }
+ },
+ "filesystem-freenet": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-filesystem",
+ "C:\\Users\\WilliamForney\\source\\repos\\wforney\\FreeNet"
+ ]
+ }
+ }
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..0d72e24
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,36 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "restore: FreeNet.slnx",
+ "type": "shell",
+ "command": "dotnet restore .\\FreeNet.slnx",
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "build: FreeNet.slnx (Debug)",
+ "type": "shell",
+ "command": "dotnet build .\\FreeNet.slnx -c Debug",
+ "problemMatcher": "$msCompile",
+ "dependsOn": "restore: FreeNet.slnx"
+ },
+ {
+ "label": "build: viruswar_server.slnx (Debug)",
+ "type": "shell",
+ "command": "dotnet build .\\viruswar\\server\\viruswar_server.slnx -c Debug",
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "run: sample server",
+ "type": "shell",
+ "command": "dotnet run --project .\\CSampleServer\\CSampleServer.csproj",
+ "problemMatcher": []
+ },
+ {
+ "label": "run: sample client",
+ "type": "shell",
+ "command": "dotnet run --project .\\CSampleClient\\CSampleClient.csproj",
+ "problemMatcher": []
+ }
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..c764db1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,55 @@
+# Agent instructions (repository-wide)
+
+This repository uses a small async TCP networking core (`FreeNet/`) with sample app surfaces (`CSampleServer/`, `CSampleClient/`, `viruswar/server/GameServer/`).
+
+## Execute-first command set
+
+- Restore/build main solution:
+ - `dotnet restore .\FreeNet.slnx`
+ - `dotnet build .\FreeNet.slnx -c Debug`
+- Build VirusWar server solution:
+ - `dotnet build .\viruswar\server\viruswar_server.slnx -c Debug`
+- Run sample server/client:
+ - `dotnet run --project .\CSampleServer\CSampleServer.csproj`
+ - `dotnet run --project .\CSampleClient\CSampleClient.csproj`
+
+## Architectural invariants
+
+- Packet framing is fixed-length-header-first (`Defines.HEADERSIZE = 4`), then protocol/body parsing through `CPacket` and `CMessageResolver`.
+- `CNetworkService` owns listener startup, SAEA pooling, session setup, and heartbeat lifecycle.
+- `CUserToken` is the authoritative per-connection state object; do not bypass it for send/receive/session closure.
+- `IPeer` implementations are the app boundary and must attach via `token.set_peer(this)`.
+- Dispatch mode is a deliberate switch:
+ - `CNetworkService(false)`: dispatch on IO completion threads.
+ - `CNetworkService(true)`: queue packets and dispatch on single logic thread (`CLogicMessageEntry`).
+
+## Protocol and threading conventions
+
+- Reserve protocol IDs `<= 0` for system behavior (close/heartbeat).
+- Always call `record_size()` before sending packets unless using a helper that does so immediately before transport.
+- Parse payload fields in exactly the same order they were pushed.
+- `session_created_callback` may be concurrent; lock shared collections in callback flows.
+- NuGet package versions are centrally managed at repository root via `Directory.Packages.props`.
+
+## Existing test reality
+
+- Automated tests are in:
+ - `FreeNet.Tests/FreeNet.Tests.csproj`
+ - `CSampleServer.Tests/CSampleServer.Tests.csproj`
+ - `CSampleClient.Tests/CSampleClient.Tests.csproj`
+ - `viruswar/server/GameServer.Tests/GameServer.Tests.csproj`
+- Test runner is Microsoft Testing Platform (MTP) via `global.json`.
+- Run all tests: `dotnet test .\FreeNet.slnx -c Debug`
+- Run VirusWar server tests: `dotnet test .\viruswar\server\viruswar_server.slnx -c Debug`
+
+## Source of truth
+
+- For Copilot-specific behavior, also follow `.github/copilot-instructions.md`.
+- Reusable local skills are in `skills/` (see `skills/README.md`).
+- For workflows with multiple tool paths (CLI/MCP/etc.), prefer whichever path minimizes token usage while preserving correctness.
+
+## Pull request workflow
+
+- When creating PRs, assign them to the current user/requester.
+- Add the most appropriate labels for the change.
+- Set a milestone when one applies.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..16b99bb
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,8 @@
+# Claude/OpenCode repository instructions
+
+Follow `AGENTS.md` as the primary repository instruction file.
+
+Additional reminders for this codebase:
+- Keep changes protocol-compatible across sample client/server and VirusWar server protocol enums.
+- Preserve packet framing behavior (`HEADERSIZE = 4`, size-recording before send).
+- Respect the logic-thread switch in `CNetworkService`; do not mix assumptions about thread context.
diff --git a/CSampleClient.Tests/CSampleClient.Tests.csproj b/CSampleClient.Tests/CSampleClient.Tests.csproj
new file mode 100644
index 0000000..616f518
--- /dev/null
+++ b/CSampleClient.Tests/CSampleClient.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSampleClient.Tests/ProtocolTests.cs b/CSampleClient.Tests/ProtocolTests.cs
new file mode 100644
index 0000000..443591b
--- /dev/null
+++ b/CSampleClient.Tests/ProtocolTests.cs
@@ -0,0 +1,14 @@
+namespace CSampleClient.Tests;
+
+public class ProtocolTests
+{
+ [Test]
+ public async Task Chat_protocol_values_are_stable()
+ {
+ var req = (short)Protocol.PacketProtocol.CHAT_MSG_REQ;
+ var ack = (short)Protocol.PacketProtocol.CHAT_MSG_ACK;
+
+ _ = await Assert.That(req).IsEqualTo((short)1);
+ _ = await Assert.That(ack).IsEqualTo((short)2);
+ }
+}
diff --git a/CSampleClient/App.config b/CSampleClient/App.config
deleted file mode 100644
index 8e15646..0000000
--- a/CSampleClient/App.config
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/CSampleClient/CRemoteServerPeer.cs b/CSampleClient/CRemoteServerPeer.cs
deleted file mode 100644
index 2a92eb0..0000000
--- a/CSampleClient/CRemoteServerPeer.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using FreeNet;
-
-namespace CSampleClient
-{
- using GameServer;
-
- class CRemoteServerPeer : IPeer
- {
- public CUserToken token { get; private set; }
-
- public CRemoteServerPeer(CUserToken token)
- {
- this.token = token;
- this.token.set_peer(this);
- }
-
- int recv_count = 0;
- void IPeer.on_message(CPacket msg)
- {
- System.Threading.Interlocked.Increment(ref this.recv_count);
-
- PROTOCOL protocol_id = (PROTOCOL)msg.pop_protocol_id();
- switch (protocol_id)
- {
- case PROTOCOL.CHAT_MSG_ACK:
- {
- string text = msg.pop_string();
- Console.WriteLine(string.Format("text {0}", text));
- }
- break;
- }
- }
-
- void IPeer.on_removed()
- {
- Console.WriteLine("Server removed.");
- Console.WriteLine("recv count " + this.recv_count);
- }
-
- void IPeer.send(CPacket msg)
- {
- msg.record_size();
- this.token.send(new ArraySegment(msg.buffer, 0, msg.position));
- }
-
- void IPeer.disconnect()
- {
- this.token.disconnect();
- }
- }
-}
diff --git a/CSampleClient/CSampleClient.csproj b/CSampleClient/CSampleClient.csproj
index 13a763b..dd7dc02 100644
--- a/CSampleClient/CSampleClient.csproj
+++ b/CSampleClient/CSampleClient.csproj
@@ -1,66 +1,14 @@
-
-
-
+
+
- Debug
- AnyCPU
- {3ECD16DA-BD8E-44D3-B7A1-E9F687B9B84E}
Exe
- Properties
- CSampleClient
- CSampleClient
- v4.5
- 512
+ net10.0
+ enable
-
- AnyCPU
- true
- full
- false
- bin\Debug\
- DEBUG;TRACE
- prompt
- 4
-
-
- AnyCPU
- pdbonly
- true
- bin\Release\
- TRACE
- prompt
- 4
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
- {90786d2b-f7a9-4a90-a073-838ef232ba6a}
- FreeNet
-
+
+
-
-
-
\ No newline at end of file
+
+
diff --git a/CSampleClient/Program.cs b/CSampleClient/Program.cs
index 516a6ef..4693c58 100644
--- a/CSampleClient/Program.cs
+++ b/CSampleClient/Program.cs
@@ -1,69 +1,69 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Net;
-using System.Net.Sockets;
+using CSampleClient;
using FreeNet;
+using Protocol;
+using System.Net;
-namespace CSampleClient
-{
- using GameServer;
+PacketBufferManager.Initialize(2000);
- class Program
- {
- static List game_servers = new List();
+// The NetworkService object handles asynchronous message sending and receiving.
+// Because the same logic can be used by both the server and client, create a NetworkService object and pass it to the Connector.
+NetworkService service = new(true);
- static void Main(string[] args)
- {
- CPacketBufferManager.initialize(2000);
- // CNetworkService객체는 메시지의 비동기 송,수신 처리를 수행한다.
- // 메시지 송,수신은 서버, 클라이언트 모두 동일한 로직으로 처리될 수 있으므로
- // CNetworkService객체를 생성하여 Connector객체에 넘겨준다.
- CNetworkService service = new CNetworkService(true);
+// Create a Connector with the endpoint information and provide the NetworkService object you created.
+Connector connector = new(service);
- // endpoint정보를 갖고있는 Connector생성. 만들어둔 NetworkService객체를 넣어준다.
- CConnector connector = new CConnector(service);
- // 접속 성공시 호출될 콜백 매소드 지정.
- connector.connected_callback += on_connected_gameserver;
- IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 7979);
- connector.connect(endpoint);
- //System.Threading.Thread.Sleep(10);
+var uri = "sam.gamebass.net";
+var addresses = Dns.GetHostAddresses(uri);
+
+foreach (var address in addresses)
+{
+ Console.WriteLine(address.ToString());
+}
- while (true)
- {
- Console.Write("> ");
- string line = Console.ReadLine();
- if (line == "q")
- {
- break;
- }
+List gameServers = [];
- CPacket msg = CPacket.create((short)PROTOCOL.CHAT_MSG_REQ);
- msg.push(line);
- game_servers[0].send(msg);
- }
+// Register the callback method that will be invoked when the connection succeeds.
+connector.Connected += (_, e) =>
+{
+ var serverToken = e.Token;
+ lock (gameServers)
+ {
+ IPeer server = new RemoteServerPeer(serverToken);
+ serverToken.OnConnected();
+ gameServers.Add(server);
+ Console.WriteLine("Connected!");
+ }
+};
- ((CRemoteServerPeer)game_servers[0]).token.disconnect();
+var endpoint = new IPEndPoint(addresses[0], 3369);
+await connector.ConnectAsync(endpoint);
- //System.Threading.Thread.Sleep(1000 * 20);
- Console.ReadKey();
- }
+while (true)
+{
+ Console.Write("> ");
+ var line = Console.ReadLine();
+ if (line == "q")
+ {
+ break;
+ }
- ///
- /// 접속 성공시 호출될 콜백 매소드.
- ///
- ///
- static void on_connected_gameserver(CUserToken server_token)
- {
- lock (game_servers)
- {
- IPeer server = new CRemoteServerPeer(server_token);
- server_token.on_connected();
- game_servers.Add(server);
- Console.WriteLine("Connected!");
- }
- }
- }
+ if (line?.StartsWith("move") == true)
+ {
+ var msg = Packet.Create((short)PacketProtocol.MOVE_REQ);
+ msg.Push(1.0f); // x
+ msg.Push(2.0f); // y
+ msg.Push(3.0f); // z
+ msg.Push(4.0f); // r
+ gameServers[0].Send(msg);
+ }
+ else
+ {
+ //CPacket msg = CPacket.create((short)EPacketProtocol.CHAT_MSG_REQ);
+ //msg.push(line);
+ //gameServers[0].Send(msg);
+ }
}
+
+((RemoteServerPeer)gameServers[0]).Token.Disconnect();
+
+Console.ReadKey();
diff --git a/CSampleClient/Properties/AssemblyInfo.cs b/CSampleClient/Properties/AssemblyInfo.cs
deleted file mode 100644
index 6a06a82..0000000
--- a/CSampleClient/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("CSampleClient")]
-[assembly: AssemblyDescription("")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("")]
-[assembly: AssemblyProduct("CSampleClient")]
-[assembly: AssemblyCopyright("Copyright © 2014")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components. If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]
-
-// The following GUID is for the ID of the typelib if this project is exposed to COM
-[assembly: Guid("8baac856-fb13-4788-9047-de29f10c75c4")]
-
-// Version information for an assembly consists of the following four values:
-//
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-//
-// You can specify all the values or you can default the Build and Revision Numbers
-// by using the '*' as shown below:
-// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.0.0")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/CSampleClient/RemoteServerPeer.cs b/CSampleClient/RemoteServerPeer.cs
new file mode 100644
index 0000000..d2bad9c
--- /dev/null
+++ b/CSampleClient/RemoteServerPeer.cs
@@ -0,0 +1,81 @@
+using FreeNet;
+using Protocol;
+
+namespace CSampleClient;
+
+internal class RemoteServerPeer : IPeer
+{
+ private int _receivedCount = 0;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token.
+ public RemoteServerPeer(UserToken token)
+ {
+ Token = token;
+ Token.Peer = this;
+ }
+
+ ///
+ /// Gets the token.
+ ///
+ /// The token.
+ public UserToken Token { get; private set; }
+
+ ///
+ public void Disconnect() => Token.Disconnect();
+
+ ///
+ public void OnMessage(Packet msg)
+ {
+ _ = Interlocked.Increment(ref _receivedCount);
+
+ var protocolId = msg.PopProtocolId().ToProtocol();
+ switch (protocolId)
+ {
+ //case EPacketProtocol.CHAT_MSG_ACK:
+ // {
+ // string text = msg.pop_string();
+ // Console.WriteLine(string.Format("received text {0}", text));
+ // }
+ // break;
+ case PacketProtocol.USER_INFO:
+ {
+ var id = msg.PopInt16();
+ Console.WriteLine(string.Format("yourid {0}", id));
+ }
+
+ break;
+ case PacketProtocol.MOVE_CAST:
+ {
+ var ret = new MoveCast(msg);
+ Console.WriteLine(ret.ToString());
+ //short userid = msg.PopInt16();
+ //float x = msg.PopFloat();
+ //float y = msg.PopFloat();
+ //float z = msg.PopFloat();
+ //float r = msg.PopFloat();
+ //Console.WriteLine($"move {userid} {x} {y} {z} {r}");
+ }
+
+ break;
+ default:
+ break;
+ }
+ }
+
+ ///
+ public void OnRemoved()
+ {
+ Console.WriteLine("Server removed.");
+ Console.WriteLine($"recv count {_receivedCount}");
+ }
+
+ ///
+ public void Send(Packet message)
+ {
+ message.RecordSize();
+ Token.Send(new ArraySegment(message.Buffer, 0, message.Position));
+ }
+}
diff --git a/CSampleClient/protocol.cs b/CSampleClient/protocol.cs
deleted file mode 100644
index d7b93ab..0000000
--- a/CSampleClient/protocol.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace GameServer
-{
- public enum PROTOCOL : short
- {
- BEGIN = 0,
-
- CHAT_MSG_REQ = 1,
- CHAT_MSG_ACK = 2,
-
- END
- }
-}
diff --git a/CSampleServer.Tests/CSampleServer.Tests.csproj b/CSampleServer.Tests/CSampleServer.Tests.csproj
new file mode 100644
index 0000000..7213373
--- /dev/null
+++ b/CSampleServer.Tests/CSampleServer.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSampleServer.Tests/ProtocolTests.cs b/CSampleServer.Tests/ProtocolTests.cs
new file mode 100644
index 0000000..03795f8
--- /dev/null
+++ b/CSampleServer.Tests/ProtocolTests.cs
@@ -0,0 +1,22 @@
+namespace CSampleServer.Tests;
+
+public class ProtocolTests
+{
+ [Test]
+ public async Task Chat_protocol_values_are_stable()
+ {
+ var req = (short)Protocol.PacketProtocol.CHAT_MSG_REQ;
+ var ack = (short)Protocol.PacketProtocol.CHAT_MSG_ACK;
+
+ _ = await Assert.That(req).IsEqualTo((short)1);
+ _ = await Assert.That(ack).IsEqualTo((short)2);
+ }
+
+ [Test]
+ public async Task Verify_configuration_is_available()
+ {
+ VerifierSettings.DontScrubGuids();
+ var value = Guid.NewGuid();
+ _ = await Assert.That(value).IsNotEqualTo(Guid.Empty);
+ }
+}
diff --git a/CSampleServer/App.config b/CSampleServer/App.config
deleted file mode 100644
index 8e15646..0000000
--- a/CSampleServer/App.config
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/CSampleServer/CGameUser.cs b/CSampleServer/CGameUser.cs
deleted file mode 100644
index 77f9205..0000000
--- a/CSampleServer/CGameUser.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using FreeNet;
-
-namespace CSampleServer
-{
- using GameServer;
-
- ///
- /// 하나의 session객체를 나타낸다.
- ///
- class CGameUser : IPeer
- {
- CUserToken token;
-
- public CGameUser(CUserToken token)
- {
- this.token = token;
- this.token.set_peer(this);
- }
-
- void IPeer.on_removed()
- {
- //Console.WriteLine("The client disconnected.");
-
- Program.remove_user(this);
- }
-
- public void send(CPacket msg)
- {
- msg.record_size();
- this.token.send(new ArraySegment(msg.buffer, 0, msg.position));
- }
-
- public void send(ArraySegment data)
- {
- this.token.send(data);
- }
-
- void IPeer.disconnect()
- {
- this.token.ban();
- }
-
- void IPeer.on_message(CPacket msg)
- {
- // 에코서버 테스트할 때 사용함.
- // Remove below comments to use echo server.
- //send(msg);
- //return;
-
- // ex)
- PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id();
- //Console.WriteLine("------------------------------------------------------");
- //Console.WriteLine("protocol id " + protocol);
- switch (protocol)
- {
- case PROTOCOL.CHAT_MSG_REQ:
- {
- string text = msg.pop_string();
- Console.WriteLine(string.Format("text {0}", text));
-
- CPacket response = CPacket.create((short)PROTOCOL.CHAT_MSG_ACK);
- response.push(text);
- send(response);
-
- if (text.Equals("exit"))
- {
- // 대량의 메시지를 한꺼번에 보낸 후 종료하는 시나리오 테스트.
- for (int i = 0; i < 1000; ++i)
- {
- CPacket dummy = CPacket.create((short)PROTOCOL.CHAT_MSG_ACK);
- dummy.push(i.ToString());
- send(dummy);
- }
-
- this.token.ban();
- }
- }
- break;
- }
- }
- }
-}
diff --git a/CSampleServer/CSampleServer.csproj b/CSampleServer/CSampleServer.csproj
index f4796f1..dd7dc02 100644
--- a/CSampleServer/CSampleServer.csproj
+++ b/CSampleServer/CSampleServer.csproj
@@ -1,66 +1,14 @@
-
-
-
+
+
- Debug
- AnyCPU
- {C1F5AA4B-069D-4E63-BBFC-5FCEDD182C6E}
Exe
- Properties
- CSampleServer
- CSampleServer
- v4.5
- 512
+ net10.0
+ enable
-
- AnyCPU
- true
- full
- false
- bin\Debug\
- DEBUG;TRACE
- prompt
- 4
-
-
- AnyCPU
- pdbonly
- true
- bin\Release\
- TRACE
- prompt
- 4
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
- {90786d2b-f7a9-4a90-a073-838ef232ba6a}
- FreeNet
-
+
+
-
-
-
\ No newline at end of file
+
+
diff --git a/CSampleServer/GameUser.cs b/CSampleServer/GameUser.cs
new file mode 100644
index 0000000..c8def7e
--- /dev/null
+++ b/CSampleServer/GameUser.cs
@@ -0,0 +1,111 @@
+using FreeNet;
+using Protocol;
+
+namespace CSampleServer;
+
+///
+/// Represents a single session object.
+///
+internal class GameUser : IPeer
+{
+ private readonly UserToken _token;
+
+ public GameUser(UserToken token, short sig)
+ {
+ _token = token;
+ Sig = sig;
+ _token.Peer = this;
+ }
+
+ public short Sig { get; private set; }
+
+ ///
+ public void Disconnect() => _token.Ban();
+
+ ///
+ public void OnMessage(Packet msg)
+ {
+ var protocol = msg.PopProtocolId().ToProtocol();
+ Console.WriteLine("------------------------------------------------------ protocol id " + protocol);
+
+ switch (protocol)
+ {
+ //case PacketProtocol.CHAT_MSG_REQ:
+ // ProcChat(msg);
+ // break;
+ case PacketProtocol.MOVE_REQ:
+ ProcMove(msg);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ ///
+ public void OnRemoved() =>
+ //Console.WriteLine("The client disconnected.");
+ Program.RemoveUser(this);
+
+ ///
+ public void Send(ArraySegment data) => _token.Send(data);
+
+ ///
+ public void Send(Packet msg)
+ {
+ msg.RecordSize();
+ _token.Send(new ArraySegment(msg.Buffer, 0, msg.Position));
+ }
+
+ private void ProcMove(Packet msg)
+ {
+ var moveReq = new MoveRequest(msg);
+ var ret = new MoveCast
+ {
+ UserID = Sig,
+ X = moveReq.X,
+ Y = moveReq.Y,
+ Z = moveReq.Z,
+ Rotation = moveReq.Rotation
+ };
+
+ Console.WriteLine(ret.ToString());
+
+ Program.SendAll(ret.ToPacket());
+
+ //short userid = Sig;
+ //float x = msg.PopFloat();
+ //float y = msg.PopFloat();
+ //float z = msg.PopFloat();
+ //float r = msg.PopFloat();
+ //Console.WriteLine($"move {Sig} {x} {y} {z} {r}");
+ //CPacket response = CPacket.create((short)EPacketProtocol.MOVE_CAST);
+ //response.push(userid);
+ //response.push(x);
+ //response.push(y);
+ //response.push(z);
+ //response.push(r);
+ //Program.SendAll(response);
+ }
+
+ //private static void ProcChat(CPacket msg)
+ //{
+ // string text = msg.pop_string();
+ // Console.WriteLine(string.Format("text {0}", text));
+
+ // CPacket response = CPacket.create((short)EPacketProtocol.CHAT_MSG_ACK); response.push(text); //send(response);
+
+ // Program.SendAll(response);
+
+ // //if (text.Equals("exit"))
+ // //{
+ // // for (int i = 0; i < 1000; ++i)
+ // // {
+ // // CPacket dummy = CPacket.create((short)PROTOCOL.CHAT_MSG_ACK);
+ // // dummy.push(i.ToString());
+ // // send(dummy);
+ // // }
+ // // this.token.ban();
+ // //}
+ //}
+}
diff --git a/CSampleServer/Program.cs b/CSampleServer/Program.cs
index d164b03..be1d9a8 100644
--- a/CSampleServer/Program.cs
+++ b/CSampleServer/Program.cs
@@ -1,69 +1,105 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
+using CSampleServer;
+
using FreeNet;
+using Protocol;
+using System.Collections.Concurrent;
+
+NetworkService service = new(false);
-namespace CSampleServer
+// Set callback methods.
+service.SessionCreated += (_, e) =>
{
- class Program
- {
- static List userlist;
-
- static void Main(string[] args)
- {
- userlist = new List();
-
- CNetworkService service = new CNetworkService(false);
- // 콜백 매소드 설정.
- service.session_created_callback += on_session_created;
- // 초기화.
- service.initialize(10000, 1024);
- service.listen("0.0.0.0", 7979, 100);
-
- // 서버에서 하트비트 체크를 끌때 사용함.
- // 스트레스 테스트를 하기 위해 FreeNet이 아닌 다른 클라이언트를 쓰는 경우등에 필요할것 같다.
- // Remove below comments to disable heartbeat on server.
- // (It maybe use to stress test from another client program not using FreeNet.)
- //service.disable_heartbeat();
-
-
- Console.WriteLine("Started!");
- while (true)
- {
- //Console.Write(".");
- string input = Console.ReadLine();
- if (input.Equals("users"))
- {
- Console.WriteLine(service.usermanager.get_total_count());
- }
- System.Threading.Thread.Sleep(1000);
- }
-
- //Console.ReadKey();
- }
-
- ///
- /// 클라이언트가 접속 완료 하였을 때 호출됩니다.
- /// n개의 워커 스레드에서 호출될 수 있으므로 공유 자원 접근시 동기화 처리를 해줘야 합니다.
- ///
- ///
- static void on_session_created(CUserToken token)
- {
- CGameUser user = new CGameUser(token);
- lock (userlist)
+ var token = e.Token;
+
+ short newid = 0;
+ lock (UserIds)
+ {
+ for (short i = 1; i <= short.MaxValue; i++)
+ {
+ if (!UserIds.ContainsKey(i))
{
- userlist.Add(user);
+ newid = i;
+ _ = UserIds.TryAdd(newid, 0);
+ break;
}
}
+ }
+
+ var user = new GameUser(token, newid);
+
+ lock (Users)
+ {
+ Users.Add(user);
+ }
+};
+
+// Initialize.
+service.Listen("0.0.0.0", 3369, 100);
+
+// Use this to disable heartbeat checks on the server. It is useful for stress tests with clients
+// that do not use FreeNet. Remove the comments below to disable heartbeat on the server.
+// (It maybe use to stress test from another client program not using FreeNet.)
+service.DisableHeartbeat();
+
+Console.WriteLine("Started!");
+while (true)
+{
+ //Console.Write(".");
+ var input = Console.ReadLine();
+ if (input?.Equals("users", StringComparison.Ordinal) == true)
+ {
+ Console.WriteLine(service.Usermanager.GetTotalCount());
+ }
+
+ Thread.Sleep(1000);
+}
+
+internal partial class Program
+{
+ private static readonly ConcurrentDictionary UserIds = new();
+ private static readonly List Users = [];
+
+ public static void RemoveUser(GameUser user)
+ {
+ lock (UserIds)
+ {
+ _ = UserIds.Remove(user.Sig, out var ret);
+ }
+
+ lock (Users)
+ {
+ _ = Users.Remove(user);
+ }
+ }
- public static void remove_user(CGameUser user)
- {
- lock (userlist)
+ public static void SendAll(Packet pkt)
+ {
+ foreach (var user in Users)
+ {
+ if (UserIds.TryGetValue(user.Sig, out var ret))
{
- userlist.Remove(user);
+ if (ret == 0)
+ {
+ var msg = new UserInfo
+ {
+ UserID = user.Sig
+ };
+ //CPacket msg = CPacket.create((short)GameServer.PROTOCOL.USER_INFO);
+ //msg.push(user.sig);
+ //user.send(msg);
+
+ user.Send(msg.ToPacket());
+ if (!UserIds.TryUpdate(user.Sig, 1, 0))
+ {
+ Console.WriteLine("id info send state update fail!");
+ }
+ }
}
}
- }
+
+ foreach (var user in Users)
+ {
+ user.Send(pkt);
+ }
+ }
}
diff --git a/CSampleServer/Properties/AssemblyInfo.cs b/CSampleServer/Properties/AssemblyInfo.cs
deleted file mode 100644
index 142e96d..0000000
--- a/CSampleServer/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("CSampleServer")]
-[assembly: AssemblyDescription("")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("")]
-[assembly: AssemblyProduct("CSampleServer")]
-[assembly: AssemblyCopyright("Copyright © 2014")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components. If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]
-
-// The following GUID is for the ID of the typelib if this project is exposed to COM
-[assembly: Guid("bd41f7a4-f338-492f-8e54-7909b936bc4d")]
-
-// Version information for an assembly consists of the following four values:
-//
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-//
-// You can specify all the values or you can default the Build and Revision Numbers
-// by using the '*' as shown below:
-// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.0.0")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/CSampleServer/protocol.cs b/CSampleServer/protocol.cs
deleted file mode 100644
index d7b93ab..0000000
--- a/CSampleServer/protocol.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace GameServer
-{
- public enum PROTOCOL : short
- {
- BEGIN = 0,
-
- CHAT_MSG_REQ = 1,
- CHAT_MSG_ACK = 2,
-
- END
- }
-}
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..41c77d8
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,21 @@
+
+
+ enable
+ true
+ enable
+
+
+ 0.2.0
+ $([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))
+
+
+ $(VersionPrefix)
+ $(VersionPrefix)-dev.$(BuildTimestampUtc)
+
+
+ 0.2.0.0
+ 0.2.0.0
+ $(Version)+sha.$(SourceRevisionId)
+ $(Version)
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..5c4039e
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,12 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
diff --git a/FreeNet.Tests/CoreInfrastructureTests.cs b/FreeNet.Tests/CoreInfrastructureTests.cs
new file mode 100644
index 0000000..8ddb281
--- /dev/null
+++ b/FreeNet.Tests/CoreInfrastructureTests.cs
@@ -0,0 +1,133 @@
+namespace FreeNet.Tests;
+
+public class CoreInfrastructureTests
+{
+ [Test]
+ public async Task Packet_round_trips_all_supported_payload_types()
+ {
+ var packet = Packet.Create(321);
+ packet.Push((byte)3);
+ packet.Push((short)-7);
+ packet.Push(42);
+ packet.Push(9.25f);
+ packet.Push("world");
+ packet.RecordSize();
+
+ var parsed = new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), null!);
+
+ _ = await Assert.That(parsed.PopProtocolId()).IsEqualTo((short)321);
+ _ = await Assert.That(parsed.PopByte()).IsEqualTo((byte)3);
+ _ = await Assert.That(parsed.PopInt16()).IsEqualTo((short)-7);
+ _ = await Assert.That(parsed.PopInt32()).IsEqualTo(42);
+ _ = await Assert.That(parsed.PopFloat()).IsEqualTo(9.25f);
+ _ = await Assert.That(parsed.PopString()).IsEqualTo("world");
+ }
+
+ [Test]
+ public async Task Packet_copy_to_preserves_protocol_and_body()
+ {
+ var original = Packet.Create(77);
+ original.Push(10);
+ original.Push("copy");
+
+ var copy = new Packet();
+ original.CopyTo(copy);
+ copy.RecordSize();
+
+ var parsed = new Packet(new ArraySegment(copy.Buffer, 0, copy.Position), null!);
+
+ _ = await Assert.That(parsed.ProtocolId).IsEqualTo((short)77);
+ _ = await Assert.That(parsed.PopProtocolId()).IsEqualTo((short)77);
+ _ = await Assert.That(parsed.PopInt32()).IsEqualTo(10);
+ _ = await Assert.That(parsed.PopString()).IsEqualTo("copy");
+ }
+
+ [Test]
+ public async Task MessageResolver_handles_fragmented_and_combined_packets()
+ {
+ var resolver = new MessageResolver();
+ var completed = new List>();
+
+ var first = CreatePacketBytes(11, p => p.Push("alpha"));
+ var second = CreatePacketBytes(12, p => p.Push(99));
+ var combined = first.Concat(second).ToArray();
+
+ resolver.OnReceive(combined, 0, 3, completed.Add);
+ _ = await Assert.That(completed.Count).IsEqualTo(0);
+
+ resolver.OnReceive(combined, 3, combined.Length - 3, completed.Add);
+ _ = await Assert.That(completed.Count).IsEqualTo(2);
+
+ var parsedFirst = new Packet(completed[0], null!);
+ var parsedSecond = new Packet(completed[1], null!);
+
+ _ = await Assert.That(parsedFirst.PopProtocolId()).IsEqualTo((short)11);
+ _ = await Assert.That(parsedFirst.PopString()).IsEqualTo("alpha");
+ _ = await Assert.That(parsedSecond.PopProtocolId()).IsEqualTo((short)12);
+ _ = await Assert.That(parsedSecond.PopInt32()).IsEqualTo(99);
+ }
+
+ [Test]
+ public async Task MessageResolver_discards_invalid_message_size_and_recovers()
+ {
+ var resolver = new MessageResolver();
+ var completed = new List>();
+
+ var invalidHeaderOnly = BitConverter.GetBytes(0);
+ resolver.OnReceive(invalidHeaderOnly, 0, invalidHeaderOnly.Length, completed.Add);
+
+ var valid = CreatePacketBytes(99, p => p.Push((short)4));
+ resolver.OnReceive(valid, 0, valid.Length, completed.Add);
+
+ _ = await Assert.That(completed.Count).IsEqualTo(1);
+
+ var parsed = new Packet(completed[0], null!);
+ _ = await Assert.That(parsed.PopProtocolId()).IsEqualTo((short)99);
+ _ = await Assert.That(parsed.PopInt16()).IsEqualTo((short)4);
+ }
+
+ [Test]
+ public async Task DoubleBufferingQueue_swaps_input_to_output_on_getall()
+ {
+ var queue = new DoubleBufferingQueue();
+ queue.Enqueue(Packet.Create(1));
+ queue.Enqueue(Packet.Create(2));
+
+ var firstBatch = queue.GetAll();
+ _ = await Assert.That(firstBatch.Count).IsEqualTo(2);
+
+ _ = firstBatch.Dequeue();
+ _ = firstBatch.Dequeue();
+
+ var secondBatch = queue.GetAll();
+ _ = await Assert.That(secondBatch.Count).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task PacketBufferManager_reuses_pushed_packet_and_reallocates_when_empty()
+ {
+ PacketBufferManager.Initialize(1);
+ var first = PacketBufferManager.Pop();
+ PacketBufferManager.Push(first);
+ var second = PacketBufferManager.Pop();
+
+ _ = await Assert.That(ReferenceEquals(first, second)).IsTrue();
+
+ // Pool is now empty; next pop must allocate a new instance.
+ var third = PacketBufferManager.Pop();
+
+ _ = await Assert.That(third).IsNotNull();
+ _ = await Assert.That(ReferenceEquals(first, third)).IsFalse();
+ }
+
+ private static byte[] CreatePacketBytes(short protocol, Action writeBody)
+ {
+ var packet = Packet.Create(protocol);
+ writeBody(packet);
+ packet.RecordSize();
+
+ var bytes = new byte[packet.Position];
+ Array.Copy(packet.Buffer, bytes, packet.Position);
+ return bytes;
+ }
+}
diff --git a/FreeNet.Tests/FreeNet.Tests.csproj b/FreeNet.Tests/FreeNet.Tests.csproj
new file mode 100644
index 0000000..088c969
--- /dev/null
+++ b/FreeNet.Tests/FreeNet.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FreeNet.Tests/NetworkServiceTests.cs b/FreeNet.Tests/NetworkServiceTests.cs
new file mode 100644
index 0000000..75a9fc2
--- /dev/null
+++ b/FreeNet.Tests/NetworkServiceTests.cs
@@ -0,0 +1,49 @@
+namespace FreeNet.Tests;
+
+public class NetworkServiceTests
+{
+ [Test]
+ public async Task Connector_can_be_constructed_and_has_null_callback()
+ {
+ var service = new NetworkService();
+ var connector = new Connector(service);
+ _ = await Assert.That(connector is not null).IsTrue();
+ }
+
+ [Test]
+ public async Task Default_constructor_has_no_logic_entry()
+ {
+ var service = new NetworkService();
+ _ = await Assert.That(service.LogicEntry is null).IsTrue();
+ }
+
+ [Test]
+ public async Task Initialize_with_small_params_creates_usermanager_and_allows_session_closed_cleanup()
+ {
+ var service = new NetworkService();
+
+ // OnConnectCompleted registers the token and wires SessionClosed → OnSessionClosed
+ var token = new UserToken(null!);
+ service.OnConnectCompleted(
+ new System.Net.Sockets.Socket(
+ System.Net.Sockets.AddressFamily.InterNetwork,
+ System.Net.Sockets.SocketType.Stream,
+ System.Net.Sockets.ProtocolType.Tcp),
+ token);
+
+ _ = await Assert.That(service.Usermanager.Exists(token)).IsTrue();
+
+ // Closing fires SessionClosed → OnSessionClosed removes the token from the manager
+ token.Close();
+ await Task.Delay(50);
+
+ _ = await Assert.That(service.Usermanager.Exists(token)).IsFalse();
+ }
+
+ [Test]
+ public async Task Logic_thread_constructor_creates_logic_entry()
+ {
+ var service = new NetworkService(useLogicThread: true);
+ _ = await Assert.That(service.LogicEntry is not null).IsTrue();
+ }
+}
diff --git a/FreeNet.Tests/PacketTests.cs b/FreeNet.Tests/PacketTests.cs
new file mode 100644
index 0000000..874f606
--- /dev/null
+++ b/FreeNet.Tests/PacketTests.cs
@@ -0,0 +1,50 @@
+using NSubstitute;
+
+namespace FreeNet.Tests;
+
+public class PacketTests
+{
+ [Test]
+ public void Close_ack_message_notifies_peer()
+ {
+ var peer = Substitute.For();
+ var token = new UserToken(null!)
+ {
+ Peer = peer
+ };
+
+ var closeAck = Packet.Create(-1);
+ closeAck.RecordSize();
+ var message = new Packet(new ArraySegment(closeAck.Buffer, 0, closeAck.Position), token);
+
+ token.OnMessage(message);
+
+ peer.Received(1).OnRemoved();
+ }
+
+ [Test]
+ public async Task Packet_round_trip_preserves_payload()
+ {
+ var packet = Packet.Create(100);
+ packet.Push((short)7);
+ packet.Push(42);
+ packet.Push("hello");
+ packet.RecordSize();
+
+ var parsed = new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), null!);
+
+ _ = await Assert.That(parsed.ProtocolId).IsEqualTo((short)100);
+ _ = await Assert.That(parsed.PopProtocolId()).IsEqualTo((short)100);
+ _ = await Assert.That(parsed.PopInt16()).IsEqualTo((short)7);
+ _ = await Assert.That(parsed.PopInt32()).IsEqualTo(42);
+ _ = await Assert.That(parsed.PopString()).IsEqualTo("hello");
+ }
+
+ [Test]
+ public async Task Verify_configuration_is_available()
+ {
+ VerifierSettings.DontScrubGuids();
+ var value = Guid.NewGuid();
+ _ = await Assert.That(value).IsNotEqualTo(Guid.Empty);
+ }
+}
diff --git a/FreeNet.Tests/ServiceLifecycleTests.cs b/FreeNet.Tests/ServiceLifecycleTests.cs
new file mode 100644
index 0000000..9f47e9a
--- /dev/null
+++ b/FreeNet.Tests/ServiceLifecycleTests.cs
@@ -0,0 +1,148 @@
+using System.Reflection;
+using NSubstitute;
+
+namespace FreeNet.Tests;
+
+public class ServiceLifecycleTests
+{
+ [Test]
+ public async Task ServerUserManager_add_exists_remove_and_count_work()
+ {
+ var manager = new ServerUserManager();
+ var token = new UserToken(null!);
+
+ manager.Add(token);
+
+ _ = await Assert.That(manager.Exists(token)).IsTrue();
+ _ = await Assert.That(manager.GetTotalCount()).IsEqualTo(1);
+
+ manager.Remove(token);
+
+ _ = await Assert.That(manager.Exists(token)).IsFalse();
+ _ = await Assert.That(manager.GetTotalCount()).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task ServerUserManager_heartbeat_check_disconnects_stale_sessions()
+ {
+ var manager = new ServerUserManager();
+ var token = new UserToken(null!)
+ {
+ Socket = new System.Net.Sockets.Socket(
+ System.Net.Sockets.AddressFamily.InterNetwork,
+ System.Net.Sockets.SocketType.Stream,
+ System.Net.Sockets.ProtocolType.Tcp)
+ };
+
+ SetProperty(token, nameof(UserToken.LatestHeartbeatTime), 0L);
+
+ manager.Add(token);
+ SetField(manager, "_heartbeatDuration", 1L);
+
+ InvokeInstance(manager, "CheckHeartbeat", [null!]);
+
+ _ = await Assert.That(token.Socket is null).IsTrue();
+ }
+
+ [Test]
+ public async Task LogicMessageEntry_dispatches_only_for_registered_users()
+ {
+ var service = new NetworkService();
+ var entry = new LogicMessageEntry(service);
+
+ var inListToken = new UserToken(null!);
+ var skippedToken = new UserToken(null!);
+ var inListPeer = Substitute.For();
+ var skippedPeer = Substitute.For();
+
+ inListToken.Peer=inListPeer;
+ skippedToken.Peer=skippedPeer;
+ service.Usermanager.Add(inListToken);
+
+ var firstBytes = CreateMessageBytes(100);
+ var secondBytes = CreateMessageBytes(200);
+ var queue = new Queue(
+ [
+ new Packet(new ArraySegment(firstBytes, 0, firstBytes.Length), inListToken),
+ new Packet(new ArraySegment(secondBytes, 0, secondBytes.Length), skippedToken)
+ ]);
+
+ InvokeInstance(entry, "DispatchAll", [queue]);
+
+ inListPeer.Received(1).OnMessage(Arg.Any());
+ skippedPeer.DidNotReceive().OnMessage(Arg.Any());
+ _ = await Assert.That(queue.Count).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task NetworkService_onsessionclosed_removes_user()
+ {
+ var service = new NetworkService();
+
+ var token = new UserToken(null!);
+ service.Usermanager.Add(token);
+
+ InvokeInstance(service, "OnSessionClosed", [null!, new SessionEventArgs(token)]);
+
+ _ = await Assert.That(service.Usermanager.Exists(token)).IsFalse();
+ }
+
+ [Test]
+ public async Task HeartbeatSender_update_under_interval_does_not_send()
+ {
+ var token = new UserToken(null!);
+ var sender = new HeartbeatSender(token, interval: 3);
+
+ sender.Update(1.5f);
+
+ _ = await Assert.That(token.Socket is null).IsTrue();
+ }
+
+ [Test]
+ public async Task Peer_onmessage_parses_protocol_one_payload_without_throwing()
+ {
+ var packet = Packet.Create(1);
+ packet.Push(55);
+ packet.Push("hello");
+ packet.RecordSize();
+
+ var threw = false;
+ try
+ {
+ Peer.OnMessage(new Const(packet.Buffer));
+ }
+ catch
+ {
+ threw = true;
+ }
+
+ _ = await Assert.That(threw).IsFalse();
+ }
+
+ private static void InvokeInstance(object target, string methodName, object[] arguments)
+ {
+ var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Missing method: {methodName}");
+ _ = method.Invoke(target, arguments);
+ }
+
+ private static void SetField(object target, string fieldName, object value)
+ {
+ var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Missing field: {fieldName}");
+ field.SetValue(target, value);
+ }
+
+ private static void SetProperty(object target, string propertyName, object value)
+ {
+ var property = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Missing property: {propertyName}");
+ property.SetValue(target, value);
+ }
+
+ private static byte[] CreateMessageBytes(short protocol)
+ {
+ var packet = Packet.Create(protocol);
+ packet.RecordSize();
+ var bytes = new byte[packet.Position];
+ Array.Copy(packet.Buffer, bytes, packet.Position);
+ return bytes;
+ }
+}
diff --git a/FreeNet.Tests/UserTokenTests.cs b/FreeNet.Tests/UserTokenTests.cs
new file mode 100644
index 0000000..0781300
--- /dev/null
+++ b/FreeNet.Tests/UserTokenTests.cs
@@ -0,0 +1,203 @@
+using NSubstitute;
+
+namespace FreeNet.Tests;
+
+public class UserTokenTests
+{
+ [Test]
+ public async Task OnConnected_sets_connected_state()
+ {
+ var token = new UserToken(null!);
+
+ _ = await Assert.That(token.IsConnected()).IsFalse();
+ token.OnConnected();
+ _ = await Assert.That(token.IsConnected()).IsTrue();
+ }
+
+ [Test]
+ public async Task OnMessage_sys_update_heartbeat_updates_latest_heartbeat_time()
+ {
+ var token = new UserToken(null!);
+ var before = DateTime.Now.Ticks;
+
+ var packet = Packet.Create(UserToken.SYS_UPDATE_HEARTBEAT);
+ packet.RecordSize();
+ token.OnMessage(new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), token));
+
+ _ = await Assert.That(token.LatestHeartbeatTime >= before).IsTrue();
+ }
+
+ [Test]
+ public async Task OnMessage_sys_start_heartbeat_creates_sender_without_firing_when_auto_disabled()
+ {
+ // _autoHeartbeat is false by default, so no timer fires
+ var token = new UserToken(null!);
+ var packet = Packet.Create(UserToken.SYS_START_HEARTBEAT);
+ packet.Push((byte)5);
+ packet.RecordSize();
+ var msg = new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), token);
+
+ var threw = false;
+ try { token.OnMessage(msg); }
+ catch { threw = true; }
+
+ _ = await Assert.That(threw).IsFalse();
+ }
+
+ [Test]
+ public void OnMessage_regular_protocol_calls_peer_onmessage()
+ {
+ var peer = Substitute.For();
+ var token = new UserToken(null!)
+ {
+ Peer = peer
+ };
+
+ var packet = Packet.Create(50);
+ packet.RecordSize();
+ token.OnMessage(new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), token));
+
+ peer.Received(1).OnMessage(Arg.Any());
+ }
+
+ [Test]
+ public async Task OnMessage_without_peer_does_not_throw()
+ {
+ var token = new UserToken(null!);
+ var packet = Packet.Create(50);
+ packet.RecordSize();
+ var msg = new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), token);
+
+ var threw = false;
+ try { token.OnMessage(msg); }
+ catch { threw = true; }
+
+ _ = await Assert.That(threw).IsFalse();
+ }
+
+ [Test]
+ public async Task OnMessage_sys_close_req_triggers_close_and_notifies_peer()
+ {
+ var peer = Substitute.For();
+ var token = BuildConnectedToken();
+ token.Peer = peer;
+
+ var sessionClosedFired = false;
+ token.SessionClosed += (_, _) => sessionClosedFired = true;
+
+ // SYS_CLOSE_REQ = 0
+ var packet = Packet.Create(0);
+ packet.RecordSize();
+ token.OnMessage(new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), token));
+
+ // Disconnect → Close → re-sends SYS_CLOSE_ACK → peer.OnRemoved
+ peer.Received(1).OnRemoved();
+ _ = await Assert.That(sessionClosedFired).IsTrue();
+ }
+
+ [Test]
+ public async Task OnMessage_peer_exception_calls_close_gracefully()
+ {
+ var peer = Substitute.For();
+ peer.When(static p => p.OnMessage(Arg.Any())).Do(static _ => throw new InvalidOperationException("peer error"));
+
+ var token = BuildConnectedToken();
+ token.Peer = peer;
+
+ var packet = Packet.Create(50);
+ packet.RecordSize();
+
+ var threw = false;
+ try { token.OnMessage(new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), token)); }
+ catch { threw = true; }
+
+ _ = await Assert.That(threw).IsFalse();
+ }
+
+ [Test]
+ public async Task OnReceive_complete_packet_with_null_peer_does_not_throw()
+ {
+ var token = new UserToken(null!);
+ var packet = Packet.Create(42);
+ packet.Push(99);
+ packet.RecordSize();
+ var bytes = new byte[packet.Position];
+ Array.Copy(packet.Buffer, bytes, packet.Position);
+
+ var threw = false;
+ try { token.OnReceive(bytes, 0, bytes.Length); }
+ catch { threw = true; }
+
+ _ = await Assert.That(threw).IsFalse();
+ }
+
+ [Test]
+ public void OnReceive_complete_packet_with_peer_dispatches_message()
+ {
+ var peer = Substitute.For();
+ var token = new UserToken(null!)
+ {
+ Peer = peer
+ };
+
+ var packet = Packet.Create(42);
+ packet.Push(99);
+ packet.RecordSize();
+ var bytes = new byte[packet.Position];
+ Array.Copy(packet.Buffer, bytes, packet.Position);
+
+ token.OnReceive(bytes, 0, bytes.Length);
+
+ peer.Received(1).OnMessage(Arg.Any());
+ }
+
+ [Test]
+ public async Task Ban_calls_close_when_socket_is_null()
+ {
+ // Token with no socket: Ban() → ByeBye() → Send (tries to write to pipe) → close path
+ var token = new UserToken(null!)
+ {
+ Peer = Substitute.For()
+ };
+ // No socket set, so send pipe writer.Complete() / Close() should not throw
+ token.Ban();
+ token.Close(); // hard-close since no I/O loop is running
+
+ _ = await Assert.That(token.Socket is null).IsTrue();
+ _ = await Assert.That(token.IsConnected()).IsFalse();
+ }
+
+ [Test]
+ public async Task DisableAutoHeartbeat_StopHeartbeat_StartHeartbeat_are_safe_without_sender()
+ {
+ var token = new UserToken(null!);
+ token.DisableAutoHeartbeat();
+ token.StopHeartbeat();
+ token.StartHeartbeat();
+
+ _ = await Assert.That(token.IsConnected()).IsFalse();
+ }
+
+ [Test]
+ public async Task UpdateHeartbeatManually_with_no_sender_does_not_throw()
+ {
+ var token = new UserToken(null!);
+ token.UpdateHeartbeatManually(99.0f);
+
+ _ = await Assert.That(token.LatestHeartbeatTime > 0).IsTrue();
+ }
+
+ /// Creates a token with a real (unconnected) socket, connected state set.
+ private static UserToken BuildConnectedToken()
+ {
+ var token = new UserToken(null!)
+ {
+ Socket = new System.Net.Sockets.Socket(
+ System.Net.Sockets.AddressFamily.InterNetwork,
+ System.Net.Sockets.SocketType.Stream,
+ System.Net.Sockets.ProtocolType.Tcp)
+ };
+ token.OnConnected();
+ return token;
+ }
+}
diff --git a/FreeNet.sln b/FreeNet.sln
deleted file mode 100644
index 094a4fa..0000000
--- a/FreeNet.sln
+++ /dev/null
@@ -1,48 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 14
-VisualStudioVersion = 14.0.25420.1
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FreeNet", "FreeNet\FreeNet.csproj", "{90786D2B-F7A9-4A90-A073-838EF232BA6A}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSampleServer", "CSampleServer\CSampleServer.csproj", "{C1F5AA4B-069D-4E63-BBFC-5FCEDD182C6E}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSampleClient", "CSampleClient\CSampleClient.csproj", "{3ECD16DA-BD8E-44D3-B7A1-E9F687B9B84E}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "viruswar", "viruswar", "{930841B7-DD91-4F95-BBB9-820AA5BB34C2}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameServer", "viruswar\server\GameServer\GameServer.csproj", "{341BA7A5-4942-48F4-885C-D186EA81BC92}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {90786D2B-F7A9-4A90-A073-838EF232BA6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {90786D2B-F7A9-4A90-A073-838EF232BA6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {90786D2B-F7A9-4A90-A073-838EF232BA6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {90786D2B-F7A9-4A90-A073-838EF232BA6A}.Release|Any CPU.Build.0 = Release|Any CPU
- {C1F5AA4B-069D-4E63-BBFC-5FCEDD182C6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {C1F5AA4B-069D-4E63-BBFC-5FCEDD182C6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {C1F5AA4B-069D-4E63-BBFC-5FCEDD182C6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {C1F5AA4B-069D-4E63-BBFC-5FCEDD182C6E}.Release|Any CPU.Build.0 = Release|Any CPU
- {3ECD16DA-BD8E-44D3-B7A1-E9F687B9B84E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {3ECD16DA-BD8E-44D3-B7A1-E9F687B9B84E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {3ECD16DA-BD8E-44D3-B7A1-E9F687B9B84E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {3ECD16DA-BD8E-44D3-B7A1-E9F687B9B84E}.Release|Any CPU.Build.0 = Release|Any CPU
- {341BA7A5-4942-48F4-885C-D186EA81BC92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {341BA7A5-4942-48F4-885C-D186EA81BC92}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {341BA7A5-4942-48F4-885C-D186EA81BC92}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {341BA7A5-4942-48F4-885C-D186EA81BC92}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {341BA7A5-4942-48F4-885C-D186EA81BC92} = {930841B7-DD91-4F95-BBB9-820AA5BB34C2}
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {0CBB3B20-9DCE-4DCB-AB98-46F6FCF5BC39}
- EndGlobalSection
-EndGlobal
diff --git a/FreeNet.slnx b/FreeNet.slnx
new file mode 100644
index 0000000..556d0a2
--- /dev/null
+++ b/FreeNet.slnx
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FreeNet/BufferManager.cs b/FreeNet/BufferManager.cs
deleted file mode 100644
index 05035f9..0000000
--- a/FreeNet/BufferManager.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using System.Net.Sockets;
-using System.Threading;
-
-namespace FreeNet
-{
- ///
- /// This class creates a single large buffer which can be divided up and assigned to SocketAsyncEventArgs objects for use
- /// with each socket I/O operation. This enables bufffers to be easily reused and gaurds against fragmenting heap memory.
- ///
- /// The operations exposed on the BufferManager class are not thread safe.
- ///
- internal class BufferManager
- {
-
- int m_numBytes; // the total number of bytes controlled by the buffer pool
- byte[] m_buffer; // the underlying byte array maintained by the Buffer Manager
- Stack m_freeIndexPool; //
- int m_currentIndex;
- int m_bufferSize;
-
- public BufferManager(int totalBytes, int bufferSize)
- {
- m_numBytes = totalBytes;
- m_currentIndex = 0;
- m_bufferSize = bufferSize;
- m_freeIndexPool = new Stack();
- }
-
- ///
- /// Allocates buffer space used by the buffer pool
- ///
- public void InitBuffer()
- {
- // create one big large buffer and divide that out to each SocketAsyncEventArg object
- m_buffer = new byte[m_numBytes];
- }
-
- ///
- /// Assigns a buffer from the buffer pool to the specified SocketAsyncEventArgs object
- ///
- /// true if the buffer was successfully set, else false
- public bool SetBuffer(SocketAsyncEventArgs args)
- {
- if (m_freeIndexPool.Count > 0)
- {
- args.SetBuffer(m_buffer, m_freeIndexPool.Pop(), m_bufferSize);
- }
- else
- {
- if ((m_numBytes - m_bufferSize) < m_currentIndex)
- {
- return false;
- }
- args.SetBuffer(m_buffer, m_currentIndex, m_bufferSize);
- m_currentIndex += m_bufferSize;
- }
- return true;
- }
-
- ///
- /// Removes the buffer from a SocketAsyncEventArg object. This frees the buffer back to the
- /// buffer pool
- ///
- public void FreeBuffer(SocketAsyncEventArgs args)
- {
- m_freeIndexPool.Push(args.Offset);
- args.SetBuffer(null, 0, 0);
- }
- }
-}
diff --git a/FreeNet/CConnector.cs b/FreeNet/CConnector.cs
deleted file mode 100644
index 236e7b0..0000000
--- a/FreeNet/CConnector.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Net;
-using System.Net.Sockets;
-
-namespace FreeNet
-{
- ///
- /// Endpoint정보를 받아서 서버에 접속한다.
- /// 접속하려는 서버 하나당 인스턴스 한개씩 생성하여 사용하면 된다.
- ///
- public class CConnector
- {
- public delegate void ConnectedHandler(CUserToken token);
- public ConnectedHandler connected_callback { get; set; }
-
- // 원격지 서버와의 연결을 위한 소켓.
- Socket client;
-
- CNetworkService network_service;
-
- public CConnector(CNetworkService network_service)
- {
- this.network_service = network_service;
- this.connected_callback = null;
- }
-
- public void connect(IPEndPoint remote_endpoint)
- {
- this.client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
- this.client.NoDelay = true;
-
- // 비동기 접속을 위한 event args.
- SocketAsyncEventArgs event_arg = new SocketAsyncEventArgs();
- event_arg.Completed += on_connect_completed;
- event_arg.RemoteEndPoint = remote_endpoint;
- bool pending = this.client.ConnectAsync(event_arg);
- if (!pending)
- {
- on_connect_completed(null, event_arg);
- }
- }
-
- void on_connect_completed(object sender, SocketAsyncEventArgs e)
- {
- if (e.SocketError == SocketError.Success)
- {
- //Console.WriteLine("Connect completd!");
- CUserToken token = new CUserToken(this.network_service.logic_entry);
-
- // 데이터 수신 준비.
- this.network_service.on_connect_completed(this.client, token);
-
- if (this.connected_callback != null)
- {
- this.connected_callback(token);
- }
- }
- else
- {
- // failed.
- Console.WriteLine(string.Format("Failed to connect. {0}", e.SocketError));
- }
- }
- }
-}
diff --git a/FreeNet/CDoubleBufferingQueue.cs b/FreeNet/CDoubleBufferingQueue.cs
deleted file mode 100644
index 3bfec27..0000000
--- a/FreeNet/CDoubleBufferingQueue.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-
-namespace FreeNet
-{
- ///
- /// 두개의 큐를 교체해가며 활용한다.
- /// IO스레드에서 입력큐에 막 쌓아놓고,
- /// 로직스레드에서 큐를 뒤바꾼뒤(swap) 쌓아놓은 패킷을 가져가 처리한다.
- /// 참고 : http://roadster.egloos.com/m/4199854
- ///
- class CDoubleBufferingQueue : ILogicQueue
- {
- // 실제 데이터가 들어갈 큐.
- Queue queue1;
- Queue queue2;
-
- // 각각의 큐에 대한 참조.
- Queue ref_input;
- Queue ref_output;
-
- object cs_write;
-
-
- public CDoubleBufferingQueue()
- {
- // 초기 세팅은 큐와 참조가 1:1로 매칭되게 설정한다.
- // ref_input - queue1
- // ref)output - queue2
- this.queue1 = new Queue();
- this.queue2 = new Queue();
- this.ref_input = this.queue1;
- this.ref_output = this.queue2;
-
- this.cs_write = new object();
- }
-
-
- ///
- /// IO스레드에서 전달한 패킷을 보관한다.
- ///
- ///
- void ILogicQueue.enqueue(CPacket msg)
- {
- lock (this.cs_write)
- {
- this.ref_input.Enqueue(msg);
- }
- }
-
-
- Queue ILogicQueue.get_all()
- {
- swap();
- return this.ref_output;
- }
-
-
- ///
- /// 입력큐와 출력큐를 뒤바꾼다.
- ///
- void swap()
- {
- lock (this.cs_write)
- {
- Queue temp = this.ref_input;
- this.ref_input = this.ref_output;
- this.ref_output = temp;
- }
- }
- }
-}
diff --git a/FreeNet/CHeartbeatSender.cs b/FreeNet/CHeartbeatSender.cs
deleted file mode 100644
index 49408d5..0000000
--- a/FreeNet/CHeartbeatSender.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading;
-
-namespace FreeNet
-{
- class CHeartbeatSender
- {
- CUserToken server;
- Timer timer_heartbeat;
- uint interval;
-
- float elapsed_time;
-
-
- public CHeartbeatSender(CUserToken server, uint interval)
- {
- this.server = server;
- this.interval = interval;
- this.timer_heartbeat = new Timer(this.on_timer, null, Timeout.Infinite, this.interval * 1000);
- }
-
-
- void on_timer(object state)
- {
- send();
- }
-
-
- void send()
- {
- CPacket msg = CPacket.create((short)CUserToken.SYS_UPDATE_HEARTBEAT);
- this.server.send(msg);
- }
-
-
- public void update(float time)
- {
- this.elapsed_time += time;
- if (this.elapsed_time < this.interval)
- {
- return;
- }
-
- this.elapsed_time = 0.0f;
- send();
- }
-
-
- public void stop()
- {
- this.elapsed_time = 0;
- this.timer_heartbeat.Change(Timeout.Infinite, Timeout.Infinite);
- }
-
-
- public void play()
- {
- this.elapsed_time = 0;
- this.timer_heartbeat.Change(0, this.interval * 1000);
- }
- }
-}
diff --git a/FreeNet/CListener.cs b/FreeNet/CListener.cs
deleted file mode 100644
index 10db13c..0000000
--- a/FreeNet/CListener.cs
+++ /dev/null
@@ -1,147 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Net;
-using System.Net.Sockets;
-using System.Threading;
-
-namespace FreeNet
-{
- class CListener
- {
- // 비동기 Accept를 위한 EventArgs.
- SocketAsyncEventArgs accept_args;
-
- Socket listen_socket;
-
- // Accept처리의 순서를 제어하기 위한 이벤트 변수.
- AutoResetEvent flow_control_event;
-
- // 새로운 클라이언트가 접속했을 때 호출되는 콜백.
- public delegate void NewclientHandler(Socket client_socket, object token);
- public NewclientHandler callback_on_newclient;
-
- public CListener()
- {
- this.callback_on_newclient = null;
- }
-
- public void start(string host, int port, int backlog)
- {
- this.listen_socket = new Socket(AddressFamily.InterNetwork,
- SocketType.Stream, ProtocolType.Tcp);
-
- IPAddress address;
- if (host == "0.0.0.0")
- {
- address = IPAddress.Any;
- }
- else
- {
- address = IPAddress.Parse(host);
- }
- IPEndPoint endpoint = new IPEndPoint(address, port);
-
- try
- {
- listen_socket.Bind(endpoint);
- listen_socket.Listen(backlog);
-
- this.accept_args = new SocketAsyncEventArgs();
- this.accept_args.Completed += new EventHandler(on_accept_completed);
-
- Thread listen_thread = new Thread(do_listen);
- listen_thread.Start();
- }
- catch (Exception e)
- {
- //Console.WriteLine(e.Message);
- }
- }
-
- ///
- /// 루프를 돌며 클라이언트를 받아들입니다.
- /// 하나의 접속 처리가 완료된 후 다음 accept를 수행하기 위해서 event객체를 통해 흐름을 제어하도록 구현되어 있습니다.
- ///
- void do_listen()
- {
- this.flow_control_event = new AutoResetEvent(false);
-
- while (true)
- {
- // SocketAsyncEventArgs를 재사용 하기 위해서 null로 만들어 준다.
- this.accept_args.AcceptSocket = null;
-
- bool pending = true;
- try
- {
- // 비동기 accept를 호출하여 클라이언트의 접속을 받아들입니다.
- // 비동기 매소드 이지만 동기적으로 수행이 완료될 경우도 있으니
- // 리턴값을 확인하여 분기시켜야 합니다.
- pending = listen_socket.AcceptAsync(this.accept_args);
- }
- catch (Exception e)
- {
- //Console.WriteLine(e.Message);
- continue;
- }
-
- // 즉시 완료 되면 이벤트가 발생하지 않으므로 리턴값이 false일 경우 콜백 매소드를 직접 호출해 줍니다.
- // pending상태라면 비동기 요청이 들어간 상태이므로 콜백 매소드를 기다리면 됩니다.
- // http://msdn.microsoft.com/ko-kr/library/system.net.sockets.socket.acceptasync%28v=vs.110%29.aspx
- if (!pending)
- {
- on_accept_completed(null, this.accept_args);
- }
-
- // 클라이언트 접속 처리가 완료되면 이벤트 객체의 신호를 전달받아 다시 루프를 수행하도록 합니다.
- this.flow_control_event.WaitOne();
-
- // *팁 : 반드시 WaitOne -> Set 순서로 호출 되야 하는 것은 아닙니다.
- // Accept작업이 굉장히 빨리 끝나서 Set -> WaitOne 순서로 호출된다고 하더라도
- // 다음 Accept 호출 까지 문제 없이 이루어 집니다.
- // WaitOne매소드가 호출될 때 이벤트 객체가 이미 signalled 상태라면 스레드를 대기 하지 않고 계속 진행하기 때문입니다.
- }
- }
-
- ///
- /// AcceptAsync의 콜백 매소드
- ///
- ///
- /// AcceptAsync 매소드 호출시 사용된 EventArgs
- void on_accept_completed(object sender, SocketAsyncEventArgs e)
- {
- if (e.SocketError == SocketError.Success)
- {
- // 새로 생긴 소켓을 보관해 놓은뒤~
- Socket client_socket = e.AcceptSocket;
- client_socket.NoDelay = true;
-
- // 이 클래스에서는 accept까지의 역할만 수행하고 클라이언트의 접속 이후의 처리는
- // 외부로 넘기기 위해서 콜백 매소드를 호출해 주도록 합니다.
- // 이유는 소켓 처리부와 컨텐츠 구현부를 분리하기 위함입니다.
- // 컨텐츠 구현부분은 자주 바뀔 가능성이 있지만, 소켓 Accept부분은 상대적으로 변경이 적은 부분이기 때문에
- // 양쪽을 분리시켜주는것이 좋습니다.
- // 또한 클래스 설계 방침에 따라 Listen에 관련된 코드만 존재하도록 하기 위한 이유도 있습니다.
- if (this.callback_on_newclient != null)
- {
- this.callback_on_newclient(client_socket, e.UserToken);
- }
-
- // 다음 연결을 받아들인다.
- this.flow_control_event.Set();
-
- return;
- }
- else
- {
- //todo:Accept 실패 처리.
- Console.WriteLine("Failed to accept client. " + e.SocketError);
- }
-
- // 다음 연결을 받아들인다.
- this.flow_control_event.Set();
- }
- }
-}
diff --git a/FreeNet/CLogicMessageEntry.cs b/FreeNet/CLogicMessageEntry.cs
deleted file mode 100644
index f20894f..0000000
--- a/FreeNet/CLogicMessageEntry.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading;
-
-namespace FreeNet
-{
- ///
- /// 수신된 패킷을 받아 로직 스레드에서 분배하는 역할을 담당한다.
- ///
- public class CLogicMessageEntry : IMessageDispatcher
- {
- CNetworkService service;
- ILogicQueue message_queue;
- AutoResetEvent logic_event;
-
-
- public CLogicMessageEntry(CNetworkService service)
- {
- this.service = service;
- this.message_queue = new CDoubleBufferingQueue();
- this.logic_event = new AutoResetEvent(false);
- }
-
-
- ///
- /// 로직 스레드 시작.
- ///
- public void start()
- {
- Thread logic = new Thread(this.do_logic);
- logic.Start();
- }
-
-
- void IMessageDispatcher.on_message(CUserToken user, ArraySegment buffer)
- {
- // 여긴 IO스레드에서 호출된다.
- // 완성된 패킷을 메시지큐에 넣어준다.
- CPacket msg = new CPacket(buffer, user);
- this.message_queue.enqueue(msg);
-
- // 로직 스레드를 깨워 일을 시킨다.
- this.logic_event.Set();
- }
-
-
- ///
- /// 로직 스레드.
- ///
- void do_logic()
- {
- while (true)
- {
- // 패킷이 들어오면 알아서 깨워 주겠지.
- this.logic_event.WaitOne();
-
- // 메시지를 분배한다.
- dispatch_all(this.message_queue.get_all());
- }
- }
-
-
- void dispatch_all(Queue queue)
- {
- while (queue.Count > 0)
- {
- CPacket msg = queue.Dequeue();
- if (!this.service.usermanager.is_exist(msg.owner))
- {
- continue;
- }
-
- msg.owner.on_message(msg);
- }
- }
- }
-}
diff --git a/FreeNet/CMessageResolver.cs b/FreeNet/CMessageResolver.cs
deleted file mode 100644
index 6a51060..0000000
--- a/FreeNet/CMessageResolver.cs
+++ /dev/null
@@ -1,187 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-
-namespace FreeNet
-{
- class Defines
- {
- public static readonly short HEADERSIZE = 4;
- }
-
- public delegate void CompletedMessageCallback(ArraySegment buffer);
-
- ///
- /// [header][body] 구조를 갖는 데이터를 파싱하는 클래스.
- /// - header : 데이터 사이즈. Defines.HEADERSIZE에 정의된 타입만큼의 크기를 갖는다.
- /// 2바이트일 경우 Int16, 4바이트는 Int32로 처리하면 된다.
- /// 본문의 크기가 Int16.Max값을 넘지 않는다면 2바이트로 처리하는것이 좋을것 같다.
- /// - body : 메시지 본문.
- ///
- class CMessageResolver
- {
- // 메시지 사이즈.
- int message_size;
-
- // 진행중인 버퍼.
- byte[] message_buffer = new byte[1024];
-
- // 현재 진행중인 버퍼의 인덱스를 가리키는 변수.
- // 패킷 하나를 완성한 뒤에는 0으로 초기화 시켜줘야 한다.
- int current_position;
-
- // 읽어와야 할 목표 위치.
- int position_to_read;
-
- // 남은 사이즈.
- int remain_bytes;
-
- public CMessageResolver()
- {
- this.message_size = 0;
- this.current_position = 0;
- this.position_to_read = 0;
- this.remain_bytes = 0;
- }
-
- ///
- /// 목표지점으로 설정된 위치까지의 바이트를 원본 버퍼로부터 복사한다.
- /// 데이터가 모자랄 경우 현재 남은 바이트 까지만 복사한다.
- ///
- ///
- ///
- /// 다 읽었으면 true, 데이터가 모자라서 못 읽었으면 false를 리턴한다.
- bool read_until(byte[] buffer, ref int src_position)
- {
- // 읽어와야 할 바이트.
- // 데이터가 분리되어 올 경우 이전에 읽어놓은 값을 빼줘서 부족한 만큼 읽어올 수 있도록 계산해 준다.
- int copy_size = this.position_to_read - this.current_position;
-
- // 앗! 남은 데이터가 더 적다면 가능한 만큼만 복사한다.
- if (this.remain_bytes < copy_size)
- {
- copy_size = this.remain_bytes;
- }
-
- // 버퍼에 복사.
- Array.Copy(buffer, src_position, this.message_buffer, this.current_position, copy_size);
-
- // 원본 버퍼 포지션 이동.
- src_position += copy_size;
-
- // 타겟 버퍼 포지션도 이동.
- this.current_position += copy_size;
-
- // 남은 바이트 수.
- this.remain_bytes -= copy_size;
-
- // 목표지점에 도달 못했으면 false
- if (this.current_position < this.position_to_read)
- {
- return false;
- }
-
- return true;
- }
-
- ///
- /// 소켓 버퍼로부터 데이터를 수신할 때 마다 호출된다.
- /// 데이터가 남아 있을 때 까지 계속 패킷을 만들어 callback을 호출 해 준다.
- /// 하나의 패킷을 완성하지 못했다면 버퍼에 보관해 놓은 뒤 다음 수신을 기다린다.
- ///
- ///
- ///
- ///
- public void on_receive(byte[] buffer, int offset, int transffered, CompletedMessageCallback callback)
- {
- // 이번 receive로 읽어오게 될 바이트 수.
- this.remain_bytes = transffered;
-
- // 원본 버퍼의 포지션값.
- // 패킷이 여러개 뭉쳐 올 경우 원본 버퍼의 포지션은 계속 앞으로 가야 하는데 그 처리를 위한 변수이다.
- int src_position = offset;
-
- // 남은 데이터가 있다면 계속 반복한다.
- while (this.remain_bytes > 0)
- {
- bool completed = false;
-
- // 헤더만큼 못읽은 경우 헤더를 먼저 읽는다.
- if (this.current_position < Defines.HEADERSIZE)
- {
- // 목표 지점 설정(헤더 위치까지 도달하도록 설정).
- this.position_to_read = Defines.HEADERSIZE;
-
- completed = read_until(buffer, ref src_position);
- if (!completed)
- {
- // 아직 다 못읽었으므로 다음 receive를 기다린다.
- return;
- }
-
- // 헤더 하나를 온전히 읽어왔으므로 메시지 사이즈를 구한다.
- this.message_size = get_total_message_size();
-
- // 메시지 사이즈가 0이하라면 잘못된 패킷으로 처리한다.
- // It was wrong message if size less than zero.
- if (this.message_size <= 0)
- {
- clear_buffer();
- return;
- }
-
- // 다음 목표 지점.
- this.position_to_read = this.message_size;
-
- // 헤더를 다 읽었는데 더이상 가져올 데이터가 없다면 다음 receive를 기다린다.
- // (예를들어 데이터가 조각나서 헤더만 오고 메시지는 다음번에 올 경우)
- if (this.remain_bytes <= 0)
- {
- return;
- }
- }
-
- // 메시지를 읽는다.
- completed = read_until(buffer, ref src_position);
-
- if (completed)
- {
- // 패킷 하나를 완성 했다.
- byte[] clone = new byte[this.position_to_read];
- Array.Copy(this.message_buffer, clone, this.position_to_read);
- clear_buffer();
- callback(new ArraySegment(clone, 0, this.position_to_read));
- }
- }
- }
-
- ///
- /// 헤더+바디 사이즈를 구한다.
- /// 패킷 헤더부분에 이미 전체 메시지 사이즈가 계산되어 있으므로 헤더 크기에 맞게 변환만 시켜주면 된다.
- ///
- ///
- int get_total_message_size()
- {
- if (Defines.HEADERSIZE == 2)
- {
- return BitConverter.ToInt16(this.message_buffer, 0);
- }
- else if (Defines.HEADERSIZE == 4)
- {
- return BitConverter.ToInt32(this.message_buffer, 0);
- }
-
- return 0;
- }
-
- public void clear_buffer()
- {
- Array.Clear(this.message_buffer, 0, this.message_buffer.Length);
-
- this.current_position = 0;
- this.message_size = 0;
-
- }
- }
-}
diff --git a/FreeNet/CNetworkService.cs b/FreeNet/CNetworkService.cs
deleted file mode 100644
index 996fdef..0000000
--- a/FreeNet/CNetworkService.cs
+++ /dev/null
@@ -1,285 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading;
-using System.Net;
-using System.Net.Sockets;
-
-namespace FreeNet
-{
- public class CNetworkService
- {
- SocketAsyncEventArgsPool receive_event_args_pool;
- SocketAsyncEventArgsPool send_event_args_pool;
-
- public delegate void SessionHandler(CUserToken token);
- public SessionHandler session_created_callback { get; set; }
-
- public CLogicMessageEntry logic_entry { get; private set; }
- public CServerUserManager usermanager { get; private set; }
-
-
- ///
- /// 로직 스레드를 사용하려면 use_logicthread를 true로 설정한다.
- /// -> 하나의 로직 스레드를 생성한다.
- /// -> 메시지는 큐잉되어 싱글 스레드에서 처리된다.
- ///
- /// 로직 스레드를 사용하지 않으려면 use_logicthread를 false로 설정한다.
- /// -> 별도의 로직 스레드는 생성하지 않는다.
- /// -> IO스레드에서 직접 메시지 처리를 담당하게 된다.
- ///
- /// true=Create single logic thread. false=Not use any logic thread.
- public CNetworkService(bool use_logicthread = false)
- {
- this.session_created_callback = null;
- this.usermanager = new CServerUserManager();
-
- if (use_logicthread)
- {
- this.logic_entry = new CLogicMessageEntry(this);
- this.logic_entry.start();
- }
- }
-
-
- public void initialize()
- {
- // configs.
- int max_connections = 10000;
- int buffer_size = 1024;
- initialize(max_connections, buffer_size);
- }
-
- // Initializes the server by preallocating reusable buffers and
- // context objects. These objects do not need to be preallocated
- // or reused, but it is done this way to illustrate how the API can
- // easily be used to create reusable objects to increase server performance.
- //
- public void initialize(int max_connections, int buffer_size)
- {
- // receive버퍼만 할당해 놓는다.
- // send버퍼는 보낼때마다 할당하든 풀에서 얻어오든 하기 때문에.
- int pre_alloc_count = 1;
-
- BufferManager buffer_manager = new BufferManager(max_connections * buffer_size * pre_alloc_count, buffer_size);
- this.receive_event_args_pool = new SocketAsyncEventArgsPool(max_connections);
- this.send_event_args_pool = new SocketAsyncEventArgsPool(max_connections);
-
- // Allocates one large byte buffer which all I/O operations use a piece of. This gaurds
- // against memory fragmentation
- buffer_manager.InitBuffer();
-
- // preallocate pool of SocketAsyncEventArgs objects
- SocketAsyncEventArgs arg;
-
- for (int i = 0; i < max_connections; i++)
- {
- // 더이상 UserToken을 미리 생성해 놓지 않는다.
- // 다수의 클라이언트에서 접속 -> 메시지 송수신 -> 접속 해제를 반복할 경우 문제가 생김.
- // 일단 on_new_client에서 그때 그때 생성하도록 하고,
- // 소켓이 종료되면 null로 세팅하여 오류 발생시 확실히 드러날 수 있도록 코드를 변경한다.
-
- // receive pool
- {
- //Pre-allocate a set of reusable SocketAsyncEventArgs
- arg = new SocketAsyncEventArgs();
- arg.Completed += new EventHandler(receive_completed);
- arg.UserToken = null;
-
- // assign a byte buffer from the buffer pool to the SocketAsyncEventArg object
- buffer_manager.SetBuffer(arg);
-
- // add SocketAsyncEventArg to the pool
- this.receive_event_args_pool.Push(arg);
- }
-
-
- // send pool
- {
- //Pre-allocate a set of reusable SocketAsyncEventArgs
- arg = new SocketAsyncEventArgs();
- arg.Completed += new EventHandler(send_completed);
- arg.UserToken = null;
-
- // send버퍼는 보낼때 설정한다. SetBuffer가 아닌 BufferList를 사용.
- arg.SetBuffer(null, 0, 0);
-
- // add SocketAsyncEventArg to the pool
- this.send_event_args_pool.Push(arg);
- }
- }
- }
-
- public void listen(string host, int port, int backlog)
- {
- CListener client_listener = new CListener();
- client_listener.callback_on_newclient += on_new_client;
- client_listener.start(host, port, backlog);
-
- // heartbeat.
- byte check_interval = 10;
- this.usermanager.start_heartbeat_checking(check_interval, check_interval);
- }
-
- public void disable_heartbeat()
- {
- this.usermanager.stop_heartbeat_checking();
- }
-
- ///
- /// 원격 서버에 접속 성공 했을 때 호출됩니다.
- ///
- ///
- public void on_connect_completed(Socket socket, CUserToken token)
- {
- token.on_session_closed += this.on_session_closed;
- this.usermanager.add(token);
-
- // SocketAsyncEventArgsPool에서 빼오지 않고 그때 그때 할당해서 사용한다.
- // 풀은 서버에서 클라이언트와의 통신용으로만 쓰려고 만든것이기 때문이다.
- // 클라이언트 입장에서 서버와 통신을 할 때는 접속한 서버당 두개의 EventArgs만 있으면 되기 때문에 그냥 new해서 쓴다.
- // 서버간 연결에서도 마찬가지이다.
- // 풀링처리를 하려면 c->s로 가는 별도의 풀을 만들어서 써야 한다.
- SocketAsyncEventArgs receive_event_arg = new SocketAsyncEventArgs();
- receive_event_arg.Completed += new EventHandler(receive_completed);
- receive_event_arg.UserToken = token;
- receive_event_arg.SetBuffer(new byte[1024], 0, 1024);
-
- SocketAsyncEventArgs send_event_arg = new SocketAsyncEventArgs();
- send_event_arg.Completed += new EventHandler(send_completed);
- send_event_arg.UserToken = token;
- send_event_arg.SetBuffer(null, 0, 0);
-
- begin_receive(socket, receive_event_arg, send_event_arg);
- }
-
- ///
- /// 새로운 클라이언트가 접속 성공 했을 때 호출됩니다.
- /// AcceptAsync의 콜백 매소드에서 호출되며 여러 스레드에서 동시에 호출될 수 있기 때문에 공유자원에 접근할 때는 주의해야 합니다.
- ///
- ///
- void on_new_client(Socket client_socket, object token)
- {
- // 플에서 하나 꺼내와 사용한다.
- SocketAsyncEventArgs receive_args = this.receive_event_args_pool.Pop();
- SocketAsyncEventArgs send_args = this.send_event_args_pool.Pop();
-
- // UserToken은 매번 새로 생성하여 깨끗한 인스턴스로 넣어준다.
- CUserToken user_token = new CUserToken(this.logic_entry);
- user_token.on_session_closed += this.on_session_closed;
- receive_args.UserToken = user_token;
- send_args.UserToken = user_token;
-
- this.usermanager.add(user_token);
-
- user_token.on_connected();
- if (this.session_created_callback != null)
- {
- this.session_created_callback(user_token);
- }
-
- begin_receive(client_socket, receive_args, send_args);
-
- CPacket msg = CPacket.create((short)CUserToken.SYS_START_HEARTBEAT);
- byte send_interval = 5;
- msg.push(send_interval);
- user_token.send(msg);
- }
-
- void begin_receive(Socket socket, SocketAsyncEventArgs receive_args, SocketAsyncEventArgs send_args)
- {
- // receive_args, send_args 아무곳에서나 꺼내와도 된다. 둘다 동일한 CUserToken을 물고 있다.
- CUserToken token = receive_args.UserToken as CUserToken;
- token.set_event_args(receive_args, send_args);
- // 생성된 클라이언트 소켓을 보관해 놓고 통신할 때 사용한다.
- token.socket = socket;
-
- bool pending = socket.ReceiveAsync(receive_args);
- if (!pending)
- {
- process_receive(receive_args);
- }
- }
-
- // This method is called whenever a receive or send operation is completed on a socket
- //
- // SocketAsyncEventArg associated with the completed receive operation
- void receive_completed(object sender, SocketAsyncEventArgs e)
- {
- if (e.LastOperation == SocketAsyncOperation.Receive)
- {
- process_receive(e);
- return;
- }
-
- throw new ArgumentException("The last operation completed on the socket was not a receive.");
- }
-
- // This method is called whenever a receive or send operation is completed on a socket
- //
- // SocketAsyncEventArg associated with the completed send operation
- void send_completed(object sender, SocketAsyncEventArgs e)
- {
- try
- {
- CUserToken token = e.UserToken as CUserToken;
- token.process_send(e);
- }
- catch (Exception)
- {
- }
- }
-
- // This method is invoked when an asynchronous receive operation completes.
- // If the remote host closed the connection, then the socket is closed.
- //
- private void process_receive(SocketAsyncEventArgs e)
- {
- CUserToken token = e.UserToken as CUserToken;
- if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
- {
- token.on_receive(e.Buffer, e.Offset, e.BytesTransferred);
-
- // Keep receive.
- bool pending = token.socket.ReceiveAsync(e);
- if (!pending)
- {
- // Oh! stack overflow??
- process_receive(e);
- }
- }
- else
- {
- try
- {
- token.close();
- }
- catch (Exception)
- {
- Console.WriteLine("Already closed this socket.");
- }
- }
- }
-
- void on_session_closed(CUserToken token)
- {
- this.usermanager.remove(token);
-
- // Free the SocketAsyncEventArg so they can be reused by another client
- // 버퍼는 반환할 필요가 없다. SocketAsyncEventArg가 버퍼를 물고 있기 때문에
- // 이것을 재사용 할 때 물고 있는 버퍼를 그대로 사용하면 되기 때문이다.
- if (this.receive_event_args_pool != null)
- {
- this.receive_event_args_pool.Push(token.receive_event_args);
- }
-
- if (this.send_event_args_pool != null)
- {
- this.send_event_args_pool.Push(token.send_event_args);
- }
-
- token.set_event_args(null, null);
- }
- }
-}
diff --git a/FreeNet/CPacket.cs b/FreeNet/CPacket.cs
deleted file mode 100644
index c4f1c93..0000000
--- a/FreeNet/CPacket.cs
+++ /dev/null
@@ -1,194 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-
-namespace FreeNet
-{
- ///
- /// byte[] 버퍼를 참조로 보관하여 pop_xxx 매소드 호출 순서대로 데이터 변환을 수행한다.
- ///
- public class CPacket
- {
- public CUserToken owner { get; private set; }
- public byte[] buffer { get; private set; }
- public int position { get; private set; }
- public int size { get; private set; }
-
- public Int16 protocol_id { get; private set; }
-
- public static CPacket create(Int16 protocol_id)
- {
- CPacket packet = new CPacket();
- //todo:다음 리팩토링 대상은 바로 여기다. CPacketBufferManager!!!
- //CPacket packet = CPacketBufferManager.pop();
- packet.set_protocol(protocol_id);
- return packet;
- }
-
- public static void destroy(CPacket packet)
- {
- //CPacketBufferManager.push(packet);
- }
-
- public CPacket(ArraySegment buffer, CUserToken owner)
- {
- // 참조로만 보관하여 작업한다.
- // 복사가 필요하면 별도로 구현해야 한다.
- this.buffer = buffer.Array;
-
- // 헤더는 읽을필요 없으니 그 이후부터 시작한다.
- this.position = Defines.HEADERSIZE;
- this.size = buffer.Count;
-
- // 프로토콜 아이디만 확인할 경우도 있으므로 미리 뽑아놓는다.
- this.protocol_id = pop_protocol_id();
- this.position = Defines.HEADERSIZE;
-
- this.owner = owner;
- }
-
- public CPacket(byte[] buffer, CUserToken owner)
- {
- // 참조로만 보관하여 작업한다.
- // 복사가 필요하면 별도로 구현해야 한다.
- this.buffer = buffer;
-
- // 헤더는 읽을필요 없으니 그 이후부터 시작한다.
- this.position = Defines.HEADERSIZE;
-
- this.owner = owner;
- }
-
- public CPacket()
- {
- this.buffer = new byte[1024];
- }
-
- public Int16 pop_protocol_id()
- {
- return pop_int16();
- }
-
- public void copy_to(CPacket target)
- {
- target.set_protocol(this.protocol_id);
- target.overwrite(this.buffer, this.position);
- }
-
- public void overwrite(byte[] source, int position)
- {
- Array.Copy(source, this.buffer, source.Length);
- this.position = position;
- }
-
- public byte pop_byte()
- {
- byte data = this.buffer[this.position];
- this.position += sizeof(byte);
- return data;
- }
-
- public Int16 pop_int16()
- {
- Int16 data = BitConverter.ToInt16(this.buffer, this.position);
- this.position += sizeof(Int16);
- return data;
- }
-
- public Int32 pop_int32()
- {
- Int32 data = BitConverter.ToInt32(this.buffer, this.position);
- this.position += sizeof(Int32);
- return data;
- }
-
- public string pop_string()
- {
- // 문자열 길이는 최대 2바이트 까지. 0 ~ 32767
- Int16 len = BitConverter.ToInt16(this.buffer, this.position);
- this.position += sizeof(Int16);
-
- // 인코딩은 utf8로 통일한다.
- string data = System.Text.Encoding.UTF8.GetString(this.buffer, this.position, len);
- this.position += len;
-
- return data;
- }
-
- public float pop_float()
- {
- float data = BitConverter.ToSingle(this.buffer, this.position);
- this.position += sizeof(float);
- return data;
- }
-
-
-
- public void set_protocol(Int16 protocol_id)
- {
- this.protocol_id = protocol_id;
- //this.buffer = new byte[1024];
-
- // 헤더는 나중에 넣을것이므로 데이터 부터 넣을 수 있도록 위치를 점프시켜놓는다.
- this.position = Defines.HEADERSIZE;
-
- push_int16(protocol_id);
- }
-
- public void record_size()
- {
- // header + body 를 합한 사이즈를 입력한다.
- byte[] header = BitConverter.GetBytes(this.position);
- header.CopyTo(this.buffer, 0);
- }
-
- public void push_int16(Int16 data)
- {
- byte[] temp_buffer = BitConverter.GetBytes(data);
- temp_buffer.CopyTo(this.buffer, this.position);
- this.position += temp_buffer.Length;
- }
-
- public void push(byte data)
- {
- byte[] temp_buffer = BitConverter.GetBytes(data);
- temp_buffer.CopyTo(this.buffer, this.position);
- this.position += sizeof(byte);
- }
-
- public void push(Int16 data)
- {
- byte[] temp_buffer = BitConverter.GetBytes(data);
- temp_buffer.CopyTo(this.buffer, this.position);
- this.position += temp_buffer.Length;
- }
-
- public void push(Int32 data)
- {
- byte[] temp_buffer = BitConverter.GetBytes(data);
- temp_buffer.CopyTo(this.buffer, this.position);
- this.position += temp_buffer.Length;
- }
-
- public void push(string data)
- {
- byte[] temp_buffer = Encoding.UTF8.GetBytes(data);
-
- Int16 len = (Int16)temp_buffer.Length;
- byte[] len_buffer = BitConverter.GetBytes(len);
- len_buffer.CopyTo(this.buffer, this.position);
- this.position += sizeof(Int16);
-
- temp_buffer.CopyTo(this.buffer, this.position);
- this.position += temp_buffer.Length;
- }
-
- public void push(float data)
- {
- byte[] temp_buffer = BitConverter.GetBytes(data);
- temp_buffer.CopyTo(this.buffer, this.position);
- this.position += temp_buffer.Length;
- }
- }
-}
diff --git a/FreeNet/CPacketBufferManager.cs b/FreeNet/CPacketBufferManager.cs
deleted file mode 100644
index 77630e0..0000000
--- a/FreeNet/CPacketBufferManager.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-
-namespace FreeNet
-{
- //=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
- // Not stable. Do not use this class!!
- //=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
- public class CPacketBufferManager
- {
- static object cs_buffer = new object();
- static Stack pool;
- static int pool_capacity;
-
- public static void initialize(int capacity)
- {
- pool = new Stack();
- pool_capacity = capacity;
- allocate();
- }
-
- static void allocate()
- {
- for (int i = 0; i < pool_capacity; ++i)
- {
- pool.Push(new CPacket());
- }
- }
-
- public static CPacket pop()
- {
- lock (cs_buffer)
- {
- if (pool.Count <= 0)
- {
- Console.WriteLine("reallocate.");
- allocate();
- }
-
- return pool.Pop();
- }
- }
-
- public static void push(CPacket packet)
- {
- lock(cs_buffer)
- {
- pool.Push(packet);
- }
- }
- }
-}
diff --git a/FreeNet/CPeer.cs b/FreeNet/CPeer.cs
deleted file mode 100644
index d453a52..0000000
--- a/FreeNet/CPeer.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace FreeNet
-{
- ///
- /// 하나의 session객체를 나타낸다.
- /// CUserToken보다 로직쪽에 더 근접한 성격의 클래스이다.
- ///
- class CPeer
- {
- ///
- /// 소켓 버퍼로부터 데이터를 수신하여 패킷 하나를 완성했을 때 호출 된다.
- /// 호출 흐름 : .Net Socket ReceiveAsync -> CUserToken.on_receive -> CPeer.on_message
- ///
- /// 패킷 순서에 대해서(TCP)
- /// 이 매소드는 .Net Socket의 스레드풀에 의해 작동되어 호출되므로 어느 스레드에서 호출될지 알 수 없다.
- /// 하지만 하나의 CPeer객체에 대해서는 이 매소드가 완료된 이후 다음 패킷이 들어오도록 구현되어 있으므로
- /// 클라이언트가 보낸 패킷 순서는 보장이 된다.
- ///
- /// 주의할점
- /// 이 매소드에서 다른 CPeer객체를 참조하거나 공유자원에 접근할 때는 멀티스레드 관련 문제가 발생할 수 있으므로
- /// lock등의 처리를 해줘야 한다.
- /// 게임 패킷을 처리할 때는 lock을 걸고 queue에 복사한 뒤 싱글 스레드로 처리하는것이 편하다.
- ///
- ///
- /// Socket버퍼로부터 복사된 CUserToken의 버퍼를 참조한다.
- /// 이 매소드가 리턴되면 buffer는 비워지며 다음 패킷을 담을 준비를 한다.
- /// 따라서 매소드를 리턴하기 전에 사용할 데이터를 모두 빼내야 한다.
- ///
- public void on_message(Const buffer)
- {
- CPacket msg = new CPacket(buffer.Value);
- Int16 protocol_id = msg.pop_int16();
- switch (protocol_id)
- {
- case 1:
- {
- Int32 number = msg.pop_int32();
- string text = msg.pop_string();
-
- Console.WriteLine(string.Format("[{0}] [received] {1} : {2}, {3}",
- System.Threading.Thread.CurrentThread.ManagedThreadId,
- protocol_id, number, text));
- }
- break;
- }
- }
- }
-}
diff --git a/FreeNet/CServerUserManager.cs b/FreeNet/CServerUserManager.cs
deleted file mode 100644
index c382027..0000000
--- a/FreeNet/CServerUserManager.cs
+++ /dev/null
@@ -1,95 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading;
-
-namespace FreeNet
-{
- ///
- /// 현재 접속중인 전체 유저를 관리하는 클래스.
- ///
- public class CServerUserManager
- {
- object cs_user;
- List users;
-
- Timer timer_heartbeat;
- long heartbeat_duration;
-
-
- public CServerUserManager()
- {
- this.cs_user = new object();
- this.users = new List();
- }
-
-
- public void start_heartbeat_checking(uint check_interval_sec, uint allow_duration_sec)
- {
- this.heartbeat_duration = allow_duration_sec * 10000000;
- this.timer_heartbeat = new Timer(check_heartbeat, null, 1000 * check_interval_sec, 1000 * check_interval_sec);
- }
-
-
- public void stop_heartbeat_checking()
- {
- this.timer_heartbeat.Dispose();
- }
-
-
- public void add(CUserToken user)
- {
- lock (this.cs_user)
- {
- this.users.Add(user);
- }
- }
-
-
- public void remove(CUserToken user)
- {
- lock (this.cs_user)
- {
- this.users.Remove(user);
- }
- }
-
-
- public bool is_exist(CUserToken user)
- {
- lock (this.cs_user)
- {
- return this.users.Exists(obj => obj == user);
- }
- }
-
-
- public int get_total_count()
- {
- return this.users.Count;
- }
-
-
- void check_heartbeat(object state)
- {
- long allowed_time = DateTime.Now.Ticks - this.heartbeat_duration;
-
- lock (this.cs_user)
- {
- for (int i = 0; i < this.users.Count; ++i)
- {
- long heartbeat_time = this.users[i].latest_heartbeat_time;
- if (heartbeat_time >= allowed_time)
- {
- continue;
- }
-
- this.users[i].disconnect();
- }
- }
- }
-
-
- }
-}
diff --git a/FreeNet/CUserToken.cs b/FreeNet/CUserToken.cs
deleted file mode 100644
index 447adfd..0000000
--- a/FreeNet/CUserToken.cs
+++ /dev/null
@@ -1,464 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Net.Sockets;
-using System.Threading;
-
-namespace FreeNet
-{
- public class CUserToken
- {
- enum State
- {
- // 대기중.
- Idle,
-
- // 연결됨.
- Connected,
-
- // 종료가 예약됨.
- // sending_list에 대기중인 상태에서 disconnect를 호출한 경우,
- // 남아있는 패킷을 모두 보낸 뒤 끊도록 하기 위한 상태값.
- ReserveClosing,
-
- // 소켓이 완전히 종료됨.
- Closed,
- }
-
- // 종료 요청. S -> C
- const short SYS_CLOSE_REQ = 0;
- // 종료 응답. C -> S
- const short SYS_CLOSE_ACK = -1;
- // 하트비트 시작. S -> C
- public const short SYS_START_HEARTBEAT = -2;
- // 하트비트 갱신. C -> S
- public const short SYS_UPDATE_HEARTBEAT = -3;
-
- // close중복 처리 방지를 위한 플래그.
- // 0 = 연결된 상태.
- // 1 = 종료된 상태.
- int is_closed;
-
- State current_state;
- public Socket socket { get; set; }
-
- public SocketAsyncEventArgs receive_event_args { get; private set; }
- public SocketAsyncEventArgs send_event_args { get; private set; }
-
- // 바이트를 패킷 형식으로 해석해주는 해석기.
- CMessageResolver message_resolver;
-
- // session객체. 어플리케이션 딴에서 구현하여 사용.
- IPeer peer;
-
- // BufferList적용을 위해 queue에서 list로 변경.
- List> sending_list;
- // sending_list lock처리에 사용되는 객체.
- private object cs_sending_queue;
-
- IMessageDispatcher dispatcher;
-
- public delegate void ClosedDelegate(CUserToken token);
- public ClosedDelegate on_session_closed;
-
- // heartbeat.
- public long latest_heartbeat_time { get; private set; }
- CHeartbeatSender heartbeat_sender;
- bool auto_heartbeat;
-
-
- public CUserToken(IMessageDispatcher dispatcher)
- {
- this.dispatcher = dispatcher;
- this.cs_sending_queue = new object();
-
- this.message_resolver = new CMessageResolver();
- this.peer = null;
- this.sending_list = new List>();
- this.latest_heartbeat_time = DateTime.Now.Ticks;
-
- this.current_state = State.Idle;
- }
-
- public void on_connected()
- {
- this.current_state = State.Connected;
- this.is_closed = 0;
- this.auto_heartbeat = true;
- }
-
- public void set_peer(IPeer peer)
- {
- this.peer = peer;
- }
-
- public void set_event_args(SocketAsyncEventArgs receive_event_args, SocketAsyncEventArgs send_event_args)
- {
- this.receive_event_args = receive_event_args;
- this.send_event_args = send_event_args;
- }
-
- ///
- /// 이 매소드에서 직접 바이트 데이터를 해석해도 되지만 Message resolver클래스를 따로 둔 이유는
- /// 추후에 확장성을 고려하여 다른 resolver를 구현할 때 CUserToken클래스의 코드 수정을 최소화 하기 위함이다.
- ///
- ///
- ///
- ///
- public void on_receive(byte[] buffer, int offset, int transfered)
- {
- this.message_resolver.on_receive(buffer, offset, transfered, on_message_completed);
- }
-
- void on_message_completed(ArraySegment buffer)
- {
- if (this.peer == null)
- {
- return;
- }
-
- if (this.dispatcher != null)
- {
- // 로직 스레드의 큐를 타고 호출되도록 함.
- this.dispatcher.on_message(this, buffer);
- }
- else
- {
- // IO스레드에서 직접 호출.
- CPacket msg = new CPacket(buffer, this);
- on_message(msg);
- }
- }
-
-
- public void on_message(CPacket msg)
- {
- // active close를 위한 코딩.
- // 서버에서 종료하라고 연락이 왔는지 체크한다.
- // 만약 종료신호가 맞다면 disconnect를 호출하여 받은쪽에서 먼저 종료 요청을 보낸다.
- switch (msg.protocol_id)
- {
- case SYS_CLOSE_REQ:
- disconnect();
- return;
-
- case SYS_START_HEARTBEAT:
- {
- // 순서대로 파싱해야 하므로 프로토콜 아이디는 버린다.
- msg.pop_protocol_id();
- // 전송 인터벌.
- byte interval = msg.pop_byte();
- this.heartbeat_sender = new CHeartbeatSender(this, interval);
-
- if (this.auto_heartbeat)
- {
- start_heartbeat();
- }
- }
- return;
-
- case SYS_UPDATE_HEARTBEAT:
- //Console.WriteLine("heartbeat : " + DateTime.Now);
- this.latest_heartbeat_time = DateTime.Now.Ticks;
- return;
- }
-
-
- if (this.peer != null)
- {
- try
- {
- switch (msg.protocol_id)
- {
- case SYS_CLOSE_ACK:
- this.peer.on_removed();
- break;
-
- default:
- this.peer.on_message(msg);
- break;
- }
- }
- catch (Exception)
- {
- close();
- }
- }
-
- if (msg.protocol_id == SYS_CLOSE_ACK)
- {
- if (this.on_session_closed != null)
- {
- this.on_session_closed(this);
- }
- }
- }
-
- public void close()
- {
- // 중복 수행을 막는다.
- if (Interlocked.CompareExchange(ref this.is_closed, 1, 0) == 1)
- {
- return;
- }
-
- if (this.current_state == State.Closed)
- {
- // already closed.
- return;
- }
-
- this.current_state = State.Closed;
- this.socket.Close();
- this.socket = null;
-
- this.send_event_args.UserToken = null;
- this.receive_event_args.UserToken = null;
-
- this.sending_list.Clear();
- this.message_resolver.clear_buffer();
-
- if (this.peer != null)
- {
- CPacket msg = CPacket.create((short)-1);
- if (this.dispatcher != null)
- {
- this.dispatcher.on_message(this, new ArraySegment(msg.buffer, 0, msg.position));
- }
- else
- {
- on_message(msg);
- }
- }
- }
-
-
- ///
- /// 패킷을 전송한다.
- /// 큐가 비어 있을 경우에는 큐에 추가한 뒤 바로 SendAsync매소드를 호출하고,
- /// 데이터가 들어있을 경우에는 새로 추가만 한다.
- ///
- /// 큐잉된 패킷의 전송 시점 :
- /// 현재 진행중인 SendAsync가 완료되었을 때 큐를 검사하여 나머지 패킷을 전송한다.
- ///
- ///
- public void send(ArraySegment data)
- {
- lock (this.cs_sending_queue)
- {
- this.sending_list.Add(data);
-
- if (this.sending_list.Count > 1)
- {
- // 큐에 무언가가 들어 있다면 아직 이전 전송이 완료되지 않은 상태이므로 큐에 추가만 하고 리턴한다.
- // 현재 수행중인 SendAsync가 완료된 이후에 큐를 검사하여 데이터가 있으면 SendAsync를 호출하여 전송해줄 것이다.
- return;
- }
- }
-
- start_send();
- }
-
-
- public void send(CPacket msg)
- {
- msg.record_size();
- send(new ArraySegment(msg.buffer, 0, msg.position));
- }
-
-
- ///
- /// 비동기 전송을 시작한다.
- ///
- void start_send()
- {
- try
- {
- // 성능 향상을 위해 SetBuffer에서 BufferList를 사용하는 방식으로 변경함.
- this.send_event_args.BufferList = this.sending_list;
-
- // 비동기 전송 시작.
- bool pending = this.socket.SendAsync(this.send_event_args);
- if (!pending)
- {
- process_send(this.send_event_args);
- }
- }
- catch (Exception e)
- {
- if (this.socket == null)
- {
- close();
- return;
- }
-
- Console.WriteLine("send error!! close socket. " + e.Message);
- throw new Exception(e.Message, e);
- }
- }
-
- static int sent_count = 0;
- static object cs_count = new object();
- ///
- /// 비동기 전송 완료시 호출되는 콜백 매소드.
- ///
- ///
- public void process_send(SocketAsyncEventArgs e)
- {
- if (e.BytesTransferred <= 0 || e.SocketError != SocketError.Success)
- {
- // 연결이 끊겨서 이미 소켓이 종료된 경우일 것이다.
- //Console.WriteLine(string.Format("Failed to send. error {0}, transferred {1}", e.SocketError, e.BytesTransferred));
- return;
- }
-
- lock (this.cs_sending_queue)
- {
- // 리스트에 들어있는 데이터의 총 바이트 수.
- var size = this.sending_list.Sum(obj => obj.Count);
-
- // 전송이 완료되기 전에 추가 전송 요청을 했다면 sending_list에 무언가 더 들어있을 것이다.
- if (e.BytesTransferred != size)
- {
- //todo:세그먼트 하나를 다 못보낸 경우에 대한 처리도 해줘야 함.
- // 일단 close시킴.
- if (e.BytesTransferred < this.sending_list[0].Count)
- {
- string error = string.Format("Need to send more! transferred {0}, packet size {1}", e.BytesTransferred, size);
- Console.WriteLine(error);
-
- close();
- return;
- }
-
- // 보낸 만큼 빼고 나머지 대기중인 데이터들을 한방에 보내버린다.
- int sent_index = 0;
- int sum = 0;
- for (int i = 0; i < this.sending_list.Count; ++i)
- {
- sum += this.sending_list[i].Count;
- if (sum <= e.BytesTransferred)
- {
- // 여기 까지는 전송 완료된 데이터 인덱스.
- sent_index = i;
- continue;
- }
-
- break;
- }
- // 전송 완료된것은 리스트에서 삭제한다.
- this.sending_list.RemoveRange(0, sent_index + 1);
-
- // 나머지 데이터들을 한방에 보낸다.
- start_send();
- return;
- }
-
- // 다 보냈고 더이상 보낼것도 없다.
- this.sending_list.Clear();
-
- // 종료가 예약된 경우, 보낼건 다 보냈으니 진짜 종료 처리를 진행한다.
- if (this.current_state == State.ReserveClosing)
- {
- this.socket.Shutdown(SocketShutdown.Send);
- }
- }
- }
-
-
- ///
- /// 연결을 종료한다.
- /// 주로 클라이언트에서 종료할 때 호출한다.
- ///
- public void disconnect()
- {
- // close the socket associated with the client
- try
- {
- if (this.sending_list.Count <= 0)
- {
- this.socket.Shutdown(SocketShutdown.Send);
- return;
- }
-
- this.current_state = State.ReserveClosing;
- }
- // throws if client process has already closed
- catch (Exception)
- {
- close();
- }
- }
-
-
- ///
- /// 연결을 종료한다. 단, 종료코드를 전송한 뒤 상대방이 먼저 연결을 끊게 한다.
- /// 주로 서버에서 클라이언트의 연결을 끊을 때 사용한다.
- ///
- /// TIME_WAIT상태를 서버에 남기지 않으려면 disconnect대신 이 매소드를 사용해서
- /// 클라이언트를 종료시켜야 한다.
- ///
- public void ban()
- {
- try
- {
- byebye();
- }
- catch (Exception)
- {
- close();
- }
- }
-
-
- ///
- /// 종료코드를 전송하여 상대방이 먼저 끊도록 한다.
- ///
- void byebye()
- {
- CPacket bye = CPacket.create(SYS_CLOSE_REQ);
- send(bye);
- }
-
-
- public bool is_connected()
- {
- return this.current_state == State.Connected;
- }
-
-
- public void start_heartbeat()
- {
- if (this.heartbeat_sender != null)
- {
- this.heartbeat_sender.play();
- }
- }
-
-
- public void stop_heartbeat()
- {
- if (this.heartbeat_sender != null)
- {
- this.heartbeat_sender.stop();
- }
- }
-
-
- public void disable_auto_heartbeat()
- {
- stop_heartbeat();
- this.auto_heartbeat = false;
- }
-
-
- public void update_heartbeat_manually(float time)
- {
- if (this.heartbeat_sender != null)
- {
- this.heartbeat_sender.update(time);
- }
- }
- }
-}
diff --git a/FreeNet/ConnectedEventArgs.cs b/FreeNet/ConnectedEventArgs.cs
new file mode 100644
index 0000000..016c1fa
--- /dev/null
+++ b/FreeNet/ConnectedEventArgs.cs
@@ -0,0 +1,15 @@
+namespace FreeNet;
+
+///
+/// Provides data for the event. Implements the
+///
+/// The token.
+///
+public class ConnectedEventArgs(UserToken token) : EventArgs
+{
+ ///
+ /// Gets the user token.
+ ///
+ /// The user token.
+ public UserToken Token { get; } = token;
+}
diff --git a/FreeNet/Connector.cs b/FreeNet/Connector.cs
new file mode 100644
index 0000000..4130c62
--- /dev/null
+++ b/FreeNet/Connector.cs
@@ -0,0 +1,66 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace FreeNet;
+
+///
+/// Connects to a server using endpoint information. Create and use one instance per target server you want to connect
+/// to.
+///
+public class Connector(NetworkService networkService)
+{
+ ///
+ /// Socket used to connect to the remote server.
+ ///
+ private Socket? _client;
+
+ ///
+ /// Gets or sets the connected callback.
+ ///
+ /// The connected callback.
+ public event EventHandler? Connected;
+
+ ///
+ /// Connects to the specified remote endpoint asynchronously.
+ ///
+ /// The remote endpoint.
+ /// Cancellation token.
+ public async Task ConnectAsync(IPEndPoint remoteEndpoint, CancellationToken cancellationToken = default)
+ {
+ _client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
+ {
+ NoDelay = true
+ };
+
+ try
+ {
+ await _client.ConnectAsync(remoteEndpoint, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (SocketException ex)
+ {
+ Console.WriteLine($"Failed to connect. {ex.SocketErrorCode}");
+ return;
+ }
+
+ if (_client is null)
+ {
+ return;
+ }
+
+ // Here, token represents the currently connected remote server.
+ UserToken token = new(networkService.LogicEntry);
+
+ // 1) Notify application code with the "connect completed" callback. This must happen before
+ // starting receive handling in network code so the app is fully prepared. If step 2 runs
+ // first and then step 1, packets received by network code may be missed by the app.
+ Connected?.Invoke(this, new ConnectedEventArgs(token));
+
+ // 2) Prepare data receiving. Packet receive can start immediately after this call. The
+ // application must already be ready to process packets passed from network code.
+ networkService.OnConnectCompleted(_client, token);
+ }
+}
diff --git a/FreeNet/Const.cs b/FreeNet/Const.cs
index e4631d4..27313af 100644
--- a/FreeNet/Const.cs
+++ b/FreeNet/Const.cs
@@ -1,18 +1,6 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
+namespace FreeNet;
-namespace FreeNet
-{
- public struct Const
- {
- public T Value { get; private set; }
-
- public Const(T value)
- : this()
- {
- this.Value = value;
- }
- }
-}
+///
+/// A record struct that represents a constant value of type .
+///
+public readonly record struct Const(T Value);
\ No newline at end of file
diff --git a/FreeNet/Defines.cs b/FreeNet/Defines.cs
new file mode 100644
index 0000000..9b91c30
--- /dev/null
+++ b/FreeNet/Defines.cs
@@ -0,0 +1,12 @@
+namespace FreeNet;
+
+///
+/// Defines class.
+///
+internal class Defines
+{
+ ///
+ /// The header size
+ ///
+ public static readonly short HEADERSIZE = 4;
+}
diff --git a/FreeNet/DoubleBufferingQueue.cs b/FreeNet/DoubleBufferingQueue.cs
new file mode 100644
index 0000000..2d36c60
--- /dev/null
+++ b/FreeNet/DoubleBufferingQueue.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using System.Threading;
+
+namespace FreeNet;
+
+///
+/// Uses two queues by swapping references. The I/O thread keeps enqueuing to the input queue, and the logic thread
+/// swaps queues and processes the accumulated packets from the output queue. Reference:
+/// http://roadster.egloos.com/m/4199854
+///
+internal class DoubleBufferingQueue : ILogicQueue
+{
+ ///
+ /// The first queue used for storing packets.
+ ///
+ private readonly Queue _queue1;
+
+ ///
+ /// The second queue used for storing packets.
+ ///
+ private readonly Queue _queue2;
+
+ ///
+ /// The lock used to synchronize access to the queues.
+ ///
+ private readonly Lock _writeLock;
+
+ ///
+ /// The reference input
+ ///
+ private Queue _refInput;
+
+ ///
+ /// The reference output
+ ///
+ private Queue _refOutput;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DoubleBufferingQueue()
+ {
+ // Initial mapping keeps queue and reference in a 1:1 match. refInput->queue1, refOutput->queue2
+ _queue1 = new Queue();
+ _queue2 = new Queue();
+ _refInput = _queue1;
+ _refOutput = _queue2;
+
+ _writeLock = new Lock();
+ }
+
+ ///
+ public void Enqueue(Packet msg)
+ {
+ using var scope = _writeLock.EnterScope();
+ _refInput.Enqueue(msg);
+ }
+
+ ///
+ public Queue GetAll()
+ {
+ Swap();
+ return _refOutput;
+ }
+
+ ///
+ /// Swaps the input and output queues.
+ ///
+ private void Swap()
+ {
+ using var scope = _writeLock.EnterScope();
+ (_refOutput, _refInput) = (_refInput, _refOutput);
+ }
+}
diff --git a/FreeNet/FreeNet.csproj b/FreeNet/FreeNet.csproj
index 2a60b04..c539406 100644
--- a/FreeNet/FreeNet.csproj
+++ b/FreeNet/FreeNet.csproj
@@ -1,71 +1,11 @@
-
-
-
+
+
- Debug
- AnyCPU
- {90786D2B-F7A9-4A90-A073-838EF232BA6A}
- Library
- Properties
- FreeNet
- FreeNet
- v3.5
- 512
-
+ net10.0
-
- true
- full
- false
- bin\Debug\
- DEBUG;TRACE
- prompt
- 4
-
-
- pdbonly
- true
- bin\Release\
- TRACE
- prompt
- 4
- AnyCPU
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
\ No newline at end of file
+
+
diff --git a/FreeNet/HeartbeatSender.cs b/FreeNet/HeartbeatSender.cs
new file mode 100644
index 0000000..4f0f549
--- /dev/null
+++ b/FreeNet/HeartbeatSender.cs
@@ -0,0 +1,48 @@
+namespace FreeNet;
+
+internal class HeartbeatSender
+{
+ private readonly uint _interval;
+ private readonly UserToken _server;
+ private readonly Timer _timerHeartbeat;
+ private float _elapsedTime;
+
+ public HeartbeatSender(UserToken server, uint interval)
+ {
+ _server = server;
+ _interval = interval;
+ _timerHeartbeat = new Timer(OnTimer, null, Timeout.Infinite, _interval * 1000);
+ }
+
+ public void Play()
+ {
+ _elapsedTime = 0;
+ _ = _timerHeartbeat.Change(0, _interval * 1000);
+ }
+
+ public void Stop()
+ {
+ _elapsedTime = 0;
+ _ = _timerHeartbeat.Change(Timeout.Infinite, Timeout.Infinite);
+ }
+
+ public void Update(float time)
+ {
+ _elapsedTime += time;
+ if (_elapsedTime < _interval)
+ {
+ return;
+ }
+
+ _elapsedTime = 0.0f;
+ Send();
+ }
+
+ private void OnTimer(object? state) => Send();
+
+ private void Send()
+ {
+ var msg = Packet.Create(UserToken.SYS_UPDATE_HEARTBEAT);
+ _server.Send(msg);
+ }
+}
diff --git a/FreeNet/ILogicQueue.cs b/FreeNet/ILogicQueue.cs
index 669197b..484ca4e 100644
--- a/FreeNet/ILogicQueue.cs
+++ b/FreeNet/ILogicQueue.cs
@@ -1,13 +1,21 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
+using System.Collections.Generic;
-namespace FreeNet
+namespace FreeNet;
+
+///
+/// Interface for a logic queue that handles packets in a thread-safe manner.
+///
+public interface ILogicQueue
{
- public interface ILogicQueue
- {
- void enqueue(CPacket msg);
- Queue get_all();
- }
-}
+ ///
+ /// Enqueues the specified .
+ ///
+ /// The packet.
+ void Enqueue(Packet message);
+
+ ///
+ /// Gets all the packets in the queue.
+ ///
+ /// A queue containing all the packets.
+ Queue GetAll();
+}
\ No newline at end of file
diff --git a/FreeNet/IMessageDispatcher.cs b/FreeNet/IMessageDispatcher.cs
index 0a60558..2c4d22d 100644
--- a/FreeNet/IMessageDispatcher.cs
+++ b/FreeNet/IMessageDispatcher.cs
@@ -1,12 +1,16 @@
using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-namespace FreeNet
+namespace FreeNet;
+
+///
+/// Interface for message dispatching.
+///
+public interface IMessageDispatcher
{
- public interface IMessageDispatcher
- {
- void on_message(CUserToken user, ArraySegment buffer);
- }
-}
+ ///
+ /// Called when a message is received from the user.
+ ///
+ /// The user.
+ /// The buffer.
+ void OnMessage(UserToken user, ArraySegment buffer);
+}
\ No newline at end of file
diff --git a/FreeNet/IPeer.cs b/FreeNet/IPeer.cs
index 81ca3cd..1d11314 100644
--- a/FreeNet/IPeer.cs
+++ b/FreeNet/IPeer.cs
@@ -1,52 +1,33 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Net.Sockets;
-
-namespace FreeNet
+namespace FreeNet;
+
+///
+/// A shared session contract used by both server and client.
+/// On the server, it represents one client object and is created/returned from CNetworkService session callbacks.
+/// Whether to pool these objects is up to the user implementation.
+/// On the client, it represents the connected server object.
+///
+public interface IPeer
{
- ///
- /// 서버와 클라이언트에서 공통으로 사용하는 세션 객체.
- /// 서버일 경우 :
- /// 하나의 클라이언트 객체를 나타낸다.
- /// 이 인터페이스를 구현한 객체를 CNetworkService클래스의 session_created_callback호출시 생성하여 리턴시켜 준다.
- /// 객체를 풀링할지 여부는 사용자가 원하는대로 구현한다.
- ///
- /// 클라이언트일 경우 :
- /// 접속한 서버 객체를 나타낸다.
- ///
- ///
- public interface IPeer
- {
- // 제거됨.
- //void on_message(ArraySegment buffer);
-
- // 제거됨.
- //void process_user_operation(CPacket msg);
-
-
- ///
- /// CNetworkService.initialize에서 use_logicthread를 true로 설정할 경우
- /// -> IO스레드에서 직접 호출됨.
- ///
- /// false로 설정할 경우
- /// -> 로직 스레드에서 호출됨. 로직 스레드는 싱글 스레드로 돌아감.
- ///
- ///
- void on_message(CPacket msg);
-
-
- ///
- /// 원격 연결이 끊겼을 때 호출 된다.
- /// 이 매소드가 호출된 이후부터는 데이터 전송이 불가능하다.
- ///
- void on_removed();
-
-
- void send(CPacket msg);
-
-
- void disconnect();
- }
+ ///
+ /// Disconnects this instance.
+ ///
+ void Disconnect();
+
+ ///
+ /// In CNetworkService.Initialize: if use_logicthread is true, this is called directly from the I/O thread;
+ /// if false, it is called from the logic thread. The logic thread runs as a single thread.
+ ///
+ /// The packet.
+ void OnMessage(Packet message);
+
+ ///
+ /// Called when the remote connection is closed. Data can no longer be sent after this is called.
+ ///
+ void OnRemoved();
+
+ ///
+ /// Sends the specified packet.
+ ///
+ /// The packet.
+ void Send(Packet message);
}
diff --git a/FreeNet/Listener.cs b/FreeNet/Listener.cs
new file mode 100644
index 0000000..cb398b8
--- /dev/null
+++ b/FreeNet/Listener.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace FreeNet;
+
+///
+/// Listener class is responsible for accepting new client connections on a specified host and port. It uses
+/// asynchronous socket operations to handle incoming connections efficiently. The class provides a callback mechanism
+/// to notify when a new client has connected, allowing for separation of socket handling and content implementation.
+///
+internal class Listener
+{
+ ///
+ /// The callback on new client
+ ///
+ public event EventHandler? NewClientConnected;
+
+ ///
+ /// The listen socket
+ ///
+ private Socket? _listenSocket;
+
+ ///
+ /// Cancellation source for the accept loop.
+ ///
+ private CancellationTokenSource? _stopAccepting;
+
+ ///
+ /// Starts the specified host.
+ ///
+ /// The host.
+ /// The port.
+ /// The backlog.
+ public void Start(string host, int port, int backlog)
+ {
+ _listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ var address = host == "0.0.0.0" ? IPAddress.Any : IPAddress.Parse(host);
+ IPEndPoint endpoint = new(address, port);
+ try
+ {
+ _listenSocket.Bind(endpoint);
+ _listenSocket.Listen(backlog);
+ _stopAccepting = new CancellationTokenSource();
+ _ = DoListenAsync(_stopAccepting.Token);
+ }
+ catch (SocketException)
+ {
+ _listenSocket.Dispose();
+ _listenSocket = null;
+ throw;
+ }
+ }
+
+ ///
+ /// Stops accepting clients.
+ ///
+ public void Stop()
+ {
+ _stopAccepting?.Cancel();
+ _listenSocket?.Close();
+ }
+
+ ///
+ /// Accepts clients in a loop using the modern Socket.AcceptAsync API.
+ ///
+ private async Task DoListenAsync(CancellationToken cancellationToken)
+ {
+ if (_listenSocket is null)
+ {
+ return;
+ }
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var clientSocket = await _listenSocket.AcceptAsync(cancellationToken).ConfigureAwait(false);
+ clientSocket.NoDelay = true;
+ NewClientConnected?.Invoke(this, new NewClientConnectedEventArgs(clientSocket, null));
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (SocketException ex)
+ {
+ Console.WriteLine($"Failed to accept client. {ex.SocketErrorCode}");
+ }
+ }
+ }
+}
diff --git a/FreeNet/LogicMessageEntry.cs b/FreeNet/LogicMessageEntry.cs
new file mode 100644
index 0000000..db833c9
--- /dev/null
+++ b/FreeNet/LogicMessageEntry.cs
@@ -0,0 +1,85 @@
+using System.Threading.Channels;
+
+namespace FreeNet;
+
+///
+/// Receives completed packets and dispatches them on the logic thread.
+///
+public class LogicMessageEntry(NetworkService service) : IMessageDispatcher
+{
+ private readonly CancellationTokenSource _logicCancellation = new();
+
+ private readonly Channel _messageChannel = Channel.CreateUnbounded(
+ new UnboundedChannelOptions
+ {
+ SingleReader = true,
+ SingleWriter = false,
+ AllowSynchronousContinuations = false
+ });
+
+ private Task? _logicTask;
+
+ ///
+ public void OnMessage(UserToken user, ArraySegment buffer)
+ {
+ // Called on the I/O thread. Enqueue the completed packet.
+ Packet msg = new(buffer, user);
+ _ = _messageChannel.Writer.TryWrite(msg);
+ }
+
+ ///
+ /// Starts the logic dispatcher loop.
+ ///
+ public void Start()
+ {
+ if (_logicTask is not null)
+ {
+ return;
+ }
+
+ _logicTask = DoLogicAsync(_logicCancellation.Token);
+ }
+
+ ///
+ /// Stops the logic dispatcher loop.
+ ///
+ public void Stop()
+ {
+ _logicCancellation.Cancel();
+ _ = _messageChannel.Writer.TryComplete();
+ }
+
+ private void DispatchAll(Queue queue)
+ {
+ while (queue.Count > 0)
+ {
+ var msg = queue.Dequeue();
+ if (msg.Owner is null || !service.Usermanager.Exists(msg.Owner))
+ {
+ continue;
+ }
+
+ msg.Owner.OnMessage(msg);
+ }
+ }
+
+ private async Task DoLogicAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ while (await _messageChannel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ Queue pending = new();
+ while (_messageChannel.Reader.TryRead(out var msg))
+ {
+ pending.Enqueue(msg);
+ }
+
+ DispatchAll(pending);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ }
+}
diff --git a/FreeNet/Message.cs b/FreeNet/Message.cs
deleted file mode 100644
index a0b0416..0000000
--- a/FreeNet/Message.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Net;
-
-namespace FreeNet
-{
-}
diff --git a/FreeNet/MessageResolver.cs b/FreeNet/MessageResolver.cs
new file mode 100644
index 0000000..47ada9e
--- /dev/null
+++ b/FreeNet/MessageResolver.cs
@@ -0,0 +1,181 @@
+using System;
+
+namespace FreeNet;
+
+///
+/// Parses data with a [header][body] structure.
+/// - header: total message size, using the type size defined by Defines.HEADERSIZE (Int16 for 2 bytes, Int32 for 4 bytes).
+/// - body: message payload.
+/// If payload size never exceeds Int16.MaxValue, a 2-byte header is typically preferable.
+///
+internal class MessageResolver
+{
+ ///
+ /// Buffer being assembled.
+ ///
+ private readonly byte[] _messageBuffer = new byte[1024];
+
+ ///
+ /// Index into the in-progress buffer. Reset to 0 after one packet is completed.
+ ///
+ private int _currentPosition;
+
+ ///
+ /// Message size.
+ ///
+ private int _messageSize;
+
+ ///
+ /// Target position to read up to.
+ ///
+ private int _positionToRead;
+
+ ///
+ /// Remaining bytes.
+ ///
+ private int _remainBytes;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MessageResolver()
+ {
+ _messageSize = 0;
+ _currentPosition = 0;
+ _positionToRead = 0;
+ _remainBytes = 0;
+ }
+
+ ///
+ /// Clears the buffer and resets positions.
+ ///
+ public void ClearBuffer()
+ {
+ Array.Clear(_messageBuffer, 0, _messageBuffer.Length);
+
+ _currentPosition = 0;
+ _messageSize = 0;
+ }
+
+ ///
+ /// Called whenever data is received from the socket buffer.
+ /// Continues assembling packets and invokes the callback while data remains.
+ /// If a full packet cannot be completed, keeps partial data in the buffer and waits for the next receive.
+ ///
+ /// Buffer containing received data.
+ /// Start position for reading from the buffer.
+ /// Size of received data.
+ /// Callback invoked when a packet is fully assembled.
+ public void OnReceive(byte[] buffer, int offset, int transffered, Action> callback)
+ {
+ // Bytes to read from this receive.
+ _remainBytes = transffered;
+
+ // Position in the source buffer. Needed when multiple packets arrive together.
+ var src_position = offset;
+
+ // Continue while there is remaining data.
+ while (_remainBytes > 0)
+ {
+ bool completed;
+
+ // If the header is incomplete, read the header first.
+ if (_currentPosition < Defines.HEADERSIZE)
+ {
+ // Set target position to the end of the header.
+ _positionToRead = Defines.HEADERSIZE;
+
+ completed = ReadUntil(buffer, ref src_position);
+ if (!completed)
+ {
+ // Not enough data yet; wait for the next receive.
+ return;
+ }
+
+ // Header is complete, so determine total message size.
+ _messageSize = GetTotalMessageSize();
+
+ // Treat non-positive message size as an invalid packet.
+ if (_messageSize <= 0)
+ {
+ ClearBuffer();
+ return;
+ }
+
+ // Next target position.
+ _positionToRead = _messageSize;
+
+ // If only the header was received, wait for the next receive for the body.
+ if (_remainBytes <= 0)
+ {
+ return;
+ }
+ }
+
+ // Read the message body.
+ completed = ReadUntil(buffer, ref src_position);
+
+ if (completed)
+ {
+ // One packet has been fully assembled.
+ var clone = new byte[_positionToRead];
+ Array.Copy(_messageBuffer, clone, _positionToRead);
+ ClearBuffer();
+ callback(new ArraySegment(clone, 0, _positionToRead));
+ }
+ }
+ }
+
+ ///
+ /// Gets total packet size (header + body). The header already stores total message size,
+ /// so this only converts according to header width.
+ ///
+ /// Total message size.
+ private int GetTotalMessageSize()
+ {
+ if (Defines.HEADERSIZE == 2)
+ {
+ return BitConverter.ToInt16(_messageBuffer, 0);
+ }
+ else if (Defines.HEADERSIZE == 4)
+ {
+ return BitConverter.ToInt32(_messageBuffer, 0);
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Copies bytes from the source buffer up to the configured target position.
+ /// If data is insufficient, copies only the available remaining bytes.
+ ///
+ /// Buffer containing received data.
+ /// Start position for reading from the buffer.
+ /// True if target was reached; false if more data is needed.
+ private bool ReadUntil(byte[] buffer, ref int src_position)
+ {
+ // Number of bytes to copy this time, accounting for previously copied bytes.
+ var copy_size = _positionToRead - _currentPosition;
+
+ // If fewer bytes remain, copy only what is available.
+ if (_remainBytes < copy_size)
+ {
+ copy_size = _remainBytes;
+ }
+
+ // Copy into target buffer.
+ Array.Copy(buffer, src_position, _messageBuffer, _currentPosition, copy_size);
+
+ // Advance source buffer position.
+ src_position += copy_size;
+
+ // Advance target buffer position.
+ _currentPosition += copy_size;
+
+ // Update remaining byte count.
+ _remainBytes -= copy_size;
+
+ // Return false if target position has not been reached.
+ return _currentPosition >= _positionToRead;
+ }
+}
diff --git a/FreeNet/NetworkService.cs b/FreeNet/NetworkService.cs
new file mode 100644
index 0000000..d1f9413
--- /dev/null
+++ b/FreeNet/NetworkService.cs
@@ -0,0 +1,128 @@
+using System.Net.Sockets;
+
+namespace FreeNet;
+
+///
+/// Core class of FreeNet. Servers call to accept clients; clients use
+/// which calls on success. Each connection is represented by a
+/// whose async I/O is driven by System.IO.Pipelines started via .
+///
+public class NetworkService
+{
+ ///
+ /// Server-wide cancellation source; cancel this to stop all connections.
+ ///
+ private readonly CancellationTokenSource _serverCancellation = new();
+
+ private Listener? _clientListener;
+
+ ///
+ /// Set to true to process incoming packets on a single dedicated logic
+ /// thread. Set it to false to process packets directly on the async I/O tasks.
+ ///
+ public NetworkService(bool useLogicThread = false)
+ {
+ Usermanager = new ServerUserManager();
+
+ if (useLogicThread)
+ {
+ LogicEntry = new LogicMessageEntry(this);
+ LogicEntry.Start();
+ }
+ }
+
+ ///
+ /// Raised when a new session is fully initialised and ready for application use.
+ ///
+ public event EventHandler? SessionCreated;
+
+ ///
+ /// Gets the logic-thread dispatcher, or null when not used.
+ ///
+ public LogicMessageEntry? LogicEntry { get; private set; }
+
+ ///
+ /// Gets the connected-user registry.
+ ///
+ public ServerUserManager Usermanager { get; private set; }
+
+ ///
+ /// Stops the periodic heartbeat checker.
+ ///
+ public void DisableHeartbeat() => Usermanager.StopHeartbeatChecking();
+
+ ///
+ /// Starts accepting clients and the heartbeat monitor.
+ ///
+ public void Listen(string host, int port, int backlog)
+ {
+ _clientListener = new Listener();
+ _clientListener.NewClientConnected += OnNewClientConnected;
+ _clientListener.Start(host, port, backlog);
+
+ const byte checkInterval = 10;
+ Usermanager.StartHeartbeatChecking(checkInterval, checkInterval);
+ }
+
+ ///
+ /// Called after a client-side connect succeeds (from ). Registers the token, wires the
+ /// session-closed event, and starts async I/O.
+ ///
+ public void OnConnectCompleted(Socket socket, UserToken token)
+ {
+ token.SessionClosed += OnSessionClosed;
+ token.Socket = socket;
+ token.OnConnected();
+
+ Usermanager.Add(token);
+ if (socket.Connected)
+ {
+ token.StartPipelinesAsync(_serverCancellation.Token);
+ }
+ }
+
+ ///
+ /// Cancels all active connections by signalling the server cancellation token.
+ ///
+ public void StopServer() => StopService();
+
+ ///
+ /// Cancels all active connections, stops accepting new clients, and stops heartbeat checking.
+ ///
+ public void StopService()
+ {
+ _serverCancellation.Cancel();
+ _clientListener?.Stop();
+ LogicEntry?.Stop();
+ Usermanager.StopHeartbeatChecking();
+ }
+
+ ///
+ /// Invoked for each accepted client socket. Creates a , starts async I/O, raises
+ /// , and sends the heartbeat start packet.
+ ///
+ private void OnNewClientConnected(object? sender, NewClientConnectedEventArgs? e)
+ {
+ if (e?.ClientSocket is null)
+ {
+ return;
+ }
+
+ UserToken userToken = new(LogicEntry!);
+ userToken.SessionClosed += OnSessionClosed;
+ userToken.Socket = e.ClientSocket;
+ userToken.OnConnected();
+
+ Usermanager.Add(userToken);
+ userToken.StartPipelinesAsync(_serverCancellation.Token);
+
+ SessionCreated?.Invoke(this, new SessionEventArgs(userToken));
+
+ var msg = Packet.Create(UserToken.SYS_START_HEARTBEAT);
+ const byte sendInterval = 5;
+ msg.Push(sendInterval);
+ userToken.Send(msg);
+ }
+
+ private void OnSessionClosed(object? sender, SessionEventArgs e) => Usermanager.Remove(e.Token);
+}
diff --git a/FreeNet/NewClientConnectedEventArgs.cs b/FreeNet/NewClientConnectedEventArgs.cs
new file mode 100644
index 0000000..59a7cf7
--- /dev/null
+++ b/FreeNet/NewClientConnectedEventArgs.cs
@@ -0,0 +1,25 @@
+using System.Net.Sockets;
+
+namespace FreeNet;
+
+///
+/// Event arguments for the new client connection event. Contains the client socket and an associated token. This class
+/// cannot be inherited. Implements the
+///
+/// The client socket.
+/// The token.
+///
+public sealed class NewClientConnectedEventArgs(Socket clientSocket, object? token) : EventArgs
+{
+ ///
+ /// Gets the client socket.
+ ///
+ /// The client socket.
+ public Socket ClientSocket { get; } = clientSocket;
+
+ ///
+ /// Gets the token.
+ ///
+ /// The token.
+ public object? Token { get; } = token;
+}
diff --git a/FreeNet/Packet.cs b/FreeNet/Packet.cs
new file mode 100644
index 0000000..2b88cd3
--- /dev/null
+++ b/FreeNet/Packet.cs
@@ -0,0 +1,290 @@
+using System.Text;
+
+namespace FreeNet;
+
+///
+/// Holds a byte[] buffer by reference and converts data in the order pop_xxx methods are called.
+///
+public class Packet
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The buffer.
+ /// The owner.
+ public Packet(ArraySegment buffer, UserToken? owner)
+ {
+ // Operates on buffer references only. Implement copying separately if needed.
+ Buffer = buffer.Array ?? [];
+
+ // Skip the header and start after it.
+ Position = Defines.HEADERSIZE;
+ Size = buffer.Count;
+
+ // Pre-read protocol ID because some paths only need to check it.
+ ProtocolId = PopProtocolId();
+ Position = Defines.HEADERSIZE;
+
+ Owner = owner;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The buffer.
+ /// The owner.
+ public Packet(byte[] buffer, UserToken? owner)
+ {
+ // Operates on buffer references only. Implement copying separately if needed.
+ Buffer = buffer;
+
+ // Skip the header and start after it.
+ Position = Defines.HEADERSIZE;
+
+ Owner = owner;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public Packet() => Buffer = new byte[1024];
+
+ ///
+ /// Gets the buffer.
+ ///
+ /// The buffer.
+ public byte[] Buffer { get; private set; } = [];
+
+ ///
+ /// Gets the owner.
+ ///
+ /// The owner.
+ public UserToken? Owner { get; private set; }
+
+ ///
+ /// Gets the position.
+ ///
+ /// The position.
+ public int Position { get; private set; }
+
+ ///
+ /// Gets the protocol identifier.
+ ///
+ /// The protocol identifier.
+ public short ProtocolId { get; private set; }
+
+ ///
+ /// Gets the size.
+ ///
+ /// The size.
+ public int Size { get; private set; }
+
+ ///
+ /// Creates the specified protocol identifier.
+ ///
+ /// The protocol identifier.
+ /// Packet.
+ public static Packet Create(short protocol_id)
+ {
+ Packet packet = new();
+
+ // TODO: Next refactoring target is this spot: PacketBufferManager!!!
+ //Packet packet = PacketBufferManager.Pop();
+ packet.SetProtocol(protocol_id);
+ return packet;
+ }
+
+ ///
+ /// Destroys the specified packet.
+ ///
+ /// The packet.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "")]
+ public static void Destroy(Packet packet)
+ {
+ //PacketBufferManager.Push(packet);
+ }
+
+ ///
+ /// Copies to the .
+ ///
+ /// The target.
+ public void CopyTo(Packet target)
+ {
+ target.SetProtocol(ProtocolId);
+ target.Overwrite(Buffer!, Position);
+ }
+
+ ///
+ /// Overwrites the specified source.
+ ///
+ /// The source.
+ /// The position.
+ public void Overwrite(byte[] source, int position)
+ {
+ Array.Copy(source, Buffer!, source.Length);
+ Position = position;
+ }
+
+ ///
+ /// Pops the byte.
+ ///
+ /// System.Byte.
+ public byte PopByte()
+ {
+ var data = Buffer![Position];
+ Position += sizeof(byte);
+ return data;
+ }
+
+ ///
+ /// Pops the float.
+ ///
+ /// System.Single.
+ public float PopFloat()
+ {
+ var data = BitConverter.ToSingle(Buffer!, Position);
+ Position += sizeof(float);
+ return data;
+ }
+
+ ///
+ /// Pops the int16.
+ ///
+ /// System.Int16.
+ public short PopInt16()
+ {
+ var data = BitConverter.ToInt16(Buffer!, Position);
+ Position += sizeof(short);
+ return data;
+ }
+
+ ///
+ /// Pops the int32.
+ ///
+ /// System.Int32.
+ public int PopInt32()
+ {
+ var data = BitConverter.ToInt32(Buffer!, Position);
+ Position += sizeof(int);
+ return data;
+ }
+
+ ///
+ /// Pops the protocol identifier.
+ ///
+ /// System.Int16.
+ public short PopProtocolId() => PopInt16();
+
+ ///
+ /// Pops the string.
+ ///
+ /// System.String.
+ public string PopString()
+ {
+ // String length is stored in 2 bytes. Range: 0 ~ 32767.
+ var len = BitConverter.ToInt16(Buffer!, Position);
+ Position += sizeof(short);
+
+ // Standardize encoding as UTF-8.
+ var data = Encoding.UTF8.GetString(Buffer!, Position, len);
+ Position += len;
+
+ return data;
+ }
+
+ ///
+ /// Pushes the specified data.
+ ///
+ /// The data.
+ public void Push(byte data)
+ {
+ Buffer![Position] = data;
+ Position += sizeof(byte);
+ }
+
+ ///
+ /// Pushes the specified data.
+ ///
+ /// The data.
+ public void Push(short data)
+ {
+ var temp_buffer = BitConverter.GetBytes(data);
+ temp_buffer.CopyTo(Buffer!, Position);
+ Position += temp_buffer.Length;
+ }
+
+ ///
+ /// Pushes the specified data.
+ ///
+ /// The data.
+ public void Push(int data)
+ {
+ var temp_buffer = BitConverter.GetBytes(data);
+ temp_buffer.CopyTo(Buffer!, Position);
+ Position += temp_buffer.Length;
+ }
+
+ ///
+ /// Pushes the specified data.
+ ///
+ /// The data.
+ public void Push(string data)
+ {
+ var temp_buffer = Encoding.UTF8.GetBytes(data);
+
+ var len = (short)temp_buffer.Length;
+ var len_buffer = BitConverter.GetBytes(len);
+ len_buffer.CopyTo(Buffer!, Position);
+ Position += sizeof(short);
+
+ temp_buffer.CopyTo(Buffer!, Position);
+ Position += temp_buffer.Length;
+ }
+
+ ///
+ /// Pushes the specified data.
+ ///
+ /// The data.
+ public void Push(float data)
+ {
+ var temp_buffer = BitConverter.GetBytes(data);
+ temp_buffer.CopyTo(Buffer!, Position);
+ Position += temp_buffer.Length;
+ }
+
+ ///
+ /// Pushes the int16.
+ ///
+ /// The data.
+ public void PushInt16(short data)
+ {
+ var temp_buffer = BitConverter.GetBytes(data);
+ temp_buffer.CopyTo(Buffer!, Position);
+ Position += temp_buffer.Length;
+ }
+
+ ///
+ /// Records the size.
+ ///
+ public void RecordSize()
+ {
+ // Write the combined size of header + body.
+ var header = BitConverter.GetBytes(Position);
+ header.CopyTo(Buffer!, 0);
+ }
+
+ ///
+ /// Sets the protocol.
+ ///
+ /// The protocol identifier.
+ public void SetProtocol(short protocol_id)
+ {
+ ProtocolId = protocol_id;
+ //this.buffer = new byte[1024];
+
+ // Header is written later, so jump position to where data writing begins.
+ Position = Defines.HEADERSIZE;
+
+ PushInt16(protocol_id);
+ }
+}
diff --git a/FreeNet/PacketBufferManager.cs b/FreeNet/PacketBufferManager.cs
new file mode 100644
index 0000000..8abe32b
--- /dev/null
+++ b/FreeNet/PacketBufferManager.cs
@@ -0,0 +1,49 @@
+namespace FreeNet;
+
+///
+/// PacketBufferManager is a class that manages a pool of Packet objects for efficient reuse.
+///
+/// Not stable. Do not use this class!!
+public static class PacketBufferManager
+{
+ private static readonly Lock BufferLock = new();
+ private static readonly Stack Pool = new();
+ private static int s_pool_capacity;
+
+ public static void Initialize(int capacity)
+ {
+ Pool.Clear();
+ s_pool_capacity = capacity;
+ Allocate();
+ }
+
+ public static Packet Pop()
+ {
+ using (BufferLock.EnterScope())
+ {
+ if (Pool.Count <= 0)
+ {
+ Console.WriteLine("reallocate.");
+ Allocate();
+ }
+
+ return Pool.Pop();
+ }
+ }
+
+ public static void Push(Packet packet)
+ {
+ using (BufferLock.EnterScope())
+ {
+ Pool.Push(packet);
+ }
+ }
+
+ private static void Allocate()
+ {
+ for (var i = 0; i < s_pool_capacity; ++i)
+ {
+ Pool.Push(new Packet());
+ }
+ }
+}
diff --git a/FreeNet/Peer.cs b/FreeNet/Peer.cs
new file mode 100644
index 0000000..2a53956
--- /dev/null
+++ b/FreeNet/Peer.cs
@@ -0,0 +1,44 @@
+using System;
+
+namespace FreeNet;
+
+///
+/// Represents a single session object. This class is closer to logic handling than CUserToken.
+///
+internal class Peer
+{
+ ///
+ /// Called when one complete packet has been assembled from socket buffer data.
+ /// Call flow: .NET Socket ReceiveAsync -> CUserToken.on_receive -> CPeer.on_message.
+ /// For TCP packet ordering: this method runs on the .NET thread pool, so the calling thread is not fixed.
+ /// However, for a single CPeer instance, the next packet is processed only after this method completes,
+ /// so packet order from a client is preserved. Be careful with shared resources or other CPeer instances;
+ /// use proper locking to avoid multithreading issues. For game packets, queuing under lock and processing
+ /// on a single thread is often simpler.
+ ///
+ ///
+ /// References the CUserToken buffer copied from the socket buffer. When this method returns, the buffer is
+ /// cleared and reused for the next packet, so extract all required data before returning.
+ ///
+ public static void OnMessage(Const buffer)
+ {
+ Packet msg = new(buffer.Value, null);
+ var protocolId = msg.PopInt16();
+ switch (protocolId)
+ {
+ case 1:
+ var number = msg.PopInt32();
+ var text = msg.PopString();
+
+ Console.WriteLine(
+ string.Format(
+ "[{0}] [received] {1} : {2}, {3}",
+ Environment.CurrentManagedThreadId,
+ protocolId,
+ number,
+ text));
+
+ break;
+ }
+ }
+}
diff --git a/FreeNet/Properties/AssemblyInfo.cs b/FreeNet/Properties/AssemblyInfo.cs
deleted file mode 100644
index 4dcda7d..0000000
--- a/FreeNet/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("FreeNet")]
-[assembly: AssemblyDescription("")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("")]
-[assembly: AssemblyProduct("FreeNet")]
-[assembly: AssemblyCopyright("Copyright © 2014")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components. If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]
-
-// The following GUID is for the ID of the typelib if this project is exposed to COM
-[assembly: Guid("bc6ef34e-a1ef-4144-bed8-eb0b285e1836")]
-
-// Version information for an assembly consists of the following four values:
-//
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-//
-// You can specify all the values or you can default the Build and Revision Numbers
-// by using the '*' as shown below:
-// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.0.0")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/FreeNet/ServerUserManager.cs b/FreeNet/ServerUserManager.cs
new file mode 100644
index 0000000..70909c2
--- /dev/null
+++ b/FreeNet/ServerUserManager.cs
@@ -0,0 +1,105 @@
+namespace FreeNet;
+
+///
+/// Manages all currently connected users.
+///
+public class ServerUserManager
+{
+ private readonly Lock _user;
+ private readonly List _users;
+ private long _heartbeatDuration;
+ private Timer? _timerHeartbeat;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ServerUserManager()
+ {
+ _user = new Lock();
+ _users = [];
+ }
+
+ ///
+ /// Adds the specified user.
+ ///
+ /// The user.
+ public void Add(UserToken user)
+ {
+ using (_user.EnterScope())
+ {
+ _users.Add(user);
+ }
+ }
+
+ ///
+ /// Determines whether the specified user exists.
+ ///
+ /// The user.
+ /// true if the user exists; otherwise, false.
+ public bool Exists(UserToken user)
+ {
+ using (_user.EnterScope())
+ {
+ return _users.Exists(obj => obj == user);
+ }
+ }
+
+ ///
+ /// Gets the total count.
+ ///
+ /// System.Int32.
+ public int GetTotalCount()
+ {
+ using (_user.EnterScope())
+ {
+ return _users.Count;
+ }
+ }
+
+ ///
+ /// Removes the specified user.
+ ///
+ /// The user.
+ public void Remove(UserToken user)
+ {
+ using (_user.EnterScope())
+ {
+ _ = _users.Remove(user);
+ }
+ }
+
+ ///
+ /// Starts the heartbeat checking.
+ ///
+ /// The check interval in seconds.
+ /// The allowed duration in seconds.
+ public void StartHeartbeatChecking(uint checkIntervalSec, uint allowDurationSec)
+ {
+ _heartbeatDuration = allowDurationSec * 10000000;
+ _timerHeartbeat = new Timer(CheckHeartbeat, null, 1000 * checkIntervalSec, 1000 * checkIntervalSec);
+ }
+
+ ///
+ /// Stops the heartbeat checking.
+ ///
+ public void StopHeartbeatChecking() => _timerHeartbeat?.Dispose();
+
+ private void CheckHeartbeat(object? state)
+ {
+ var allowedTime = DateTime.Now.Ticks - _heartbeatDuration;
+
+ using (_user.EnterScope())
+ {
+ for (var i = 0; i < _users.Count; ++i)
+ {
+ var heartbeatTime = _users[i].LatestHeartbeatTime;
+ if (heartbeatTime >= allowedTime)
+ {
+ continue;
+ }
+
+ _users[i].Disconnect();
+ }
+ }
+ }
+}
diff --git a/FreeNet/SessionEventArgs.cs b/FreeNet/SessionEventArgs.cs
new file mode 100644
index 0000000..b105e6b
--- /dev/null
+++ b/FreeNet/SessionEventArgs.cs
@@ -0,0 +1,13 @@
+namespace FreeNet;
+
+///
+/// Provides data for session lifecycle events.
+///
+/// The session token.
+public sealed class SessionEventArgs(UserToken token) : EventArgs
+{
+ ///
+ /// Gets the session token.
+ ///
+ public UserToken Token { get; } = token;
+}
diff --git a/FreeNet/SocketAsyncEventArgsPool.cs b/FreeNet/SocketAsyncEventArgsPool.cs
deleted file mode 100644
index 922edef..0000000
--- a/FreeNet/SocketAsyncEventArgsPool.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Net.Sockets;
-
-namespace FreeNet
-{
- // Represents a collection of reusable SocketAsyncEventArgs objects.
- class SocketAsyncEventArgsPool
- {
- Stack m_pool;
-
- // Initializes the object pool to the specified size
- //
- // The "capacity" parameter is the maximum number of
- // SocketAsyncEventArgs objects the pool can hold
- public SocketAsyncEventArgsPool(int capacity)
- {
- m_pool = new Stack(capacity);
- }
-
- // Add a SocketAsyncEventArg instance to the pool
- //
- //The "item" parameter is the SocketAsyncEventArgs instance
- // to add to the pool
- public void Push(SocketAsyncEventArgs item)
- {
- if (item == null) { throw new ArgumentNullException("Items added to a SocketAsyncEventArgsPool cannot be null"); }
- lock (m_pool)
- {
- if (m_pool.Contains(item))
- {
- throw new Exception("Already exist item.");
- }
-
- m_pool.Push(item);
- }
- }
-
- // Removes a SocketAsyncEventArgs instance from the pool
- // and returns the object removed from the pool
- public SocketAsyncEventArgs Pop()
- {
- lock (m_pool)
- {
- return m_pool.Pop();
- }
- }
-
- // The number of SocketAsyncEventArgs instances in the pool
- public int Count
- {
- get { return m_pool.Count; }
- }
- }
-}
diff --git a/FreeNet/UserToken.State.cs b/FreeNet/UserToken.State.cs
new file mode 100644
index 0000000..4f16d2a
--- /dev/null
+++ b/FreeNet/UserToken.State.cs
@@ -0,0 +1,19 @@
+namespace FreeNet;
+
+public partial class UserToken
+{
+ ///
+ /// Enum representing the current connection state.
+ ///
+ private enum State
+ {
+ /// Idle — not yet connected.
+ Idle,
+
+ /// Socket is connected and I/O loops are running.
+ Connected,
+
+ /// Socket is fully closed.
+ Closed,
+ }
+}
diff --git a/FreeNet/UserToken.cs b/FreeNet/UserToken.cs
new file mode 100644
index 0000000..4ade96a
--- /dev/null
+++ b/FreeNet/UserToken.cs
@@ -0,0 +1,441 @@
+using System.IO.Pipelines;
+using System.Net.Sockets;
+
+namespace FreeNet;
+
+///
+/// Represents a user connection. I/O is handled by the async Pipelines loops.
+///
+public partial class UserToken(IMessageDispatcher? dispatcher = null)
+{
+ ///
+ /// Starts heartbeat. S → C
+ ///
+ public const short SYS_START_HEARTBEAT = -2;
+
+ ///
+ /// Updates heartbeat. C → S
+ ///
+ public const short SYS_UPDATE_HEARTBEAT = -3;
+
+ private const short SYS_CLOSE_ACK = -1;
+
+ private const short SYS_CLOSE_REQ = 0;
+
+ private readonly MessageResolver _messageResolver = new();
+
+ private readonly Pipe _sendPipe = new();
+
+ private bool _autoHeartbeat;
+
+ private State _currentState = State.Idle;
+
+ private HeartbeatSender? _heartbeatSender;
+
+ private CancellationTokenSource? _ioCancellation;
+
+ ///
+ /// Flag to prevent duplicate close handling. 0 = connected. 1 = closed.
+ ///
+ private int _isClosed;
+
+ private Task? _receiveLoopTask;
+ private Task? _sendLoopTask;
+
+ ///
+ /// Session closed event. Callback method invoked when the session ends.
+ ///
+ public event EventHandler? SessionClosed;
+
+ ///
+ /// Gets the latest heartbeat time.
+ ///
+ public long LatestHeartbeatTime { get; private set; } = DateTime.Now.Ticks;
+
+ ///
+ /// Gets or sets the peer.
+ ///
+ public IPeer? Peer { private get; set; }
+
+ ///
+ /// Gets or sets the socket.
+ ///
+ public Socket? Socket { get; set; }
+
+ ///
+ /// Ends the connection by sending a close code and letting the remote side disconnect first. Prefer this over
+ /// on the server side to avoid leaving TIME_WAIT.
+ ///
+ public void Ban()
+ {
+ try
+ {
+ ByeBye();
+ }
+ catch (Exception)
+ {
+ Close();
+ }
+ }
+
+ ///
+ /// Immediately closes the connection and notifies the peer.
+ ///
+ public void Close()
+ {
+ // Prevent duplicate execution.
+ if (Interlocked.CompareExchange(ref _isClosed, 1, 0) == 1)
+ {
+ return;
+ }
+
+ if (_currentState == State.Closed)
+ {
+ return;
+ }
+
+ _currentState = State.Closed;
+
+ // Cancel async I/O loops.
+ try
+ {
+ _ioCancellation?.Cancel();
+ }
+ catch (Exception)
+ {
+ // ignored
+ }
+
+ Socket?.Close();
+ Socket = null;
+
+ _messageResolver.ClearBuffer();
+
+ if (Peer is not null)
+ {
+ var msg = Packet.Create(-1);
+ if (dispatcher is not null)
+ {
+ dispatcher.OnMessage(this, new ArraySegment(msg.Buffer ?? [], 0, msg.Position));
+ }
+ else
+ {
+ OnMessage(msg); // fires SessionClosed internally via SYS_CLOSE_ACK path
+ }
+ }
+ else
+ {
+ // No peer registered, but still notify session-level listeners (e.g. NetworkService).
+ SessionClosed?.Invoke(this, new SessionEventArgs(this));
+ }
+ }
+
+ public void DisableAutoHeartbeat()
+ {
+ StopHeartbeat();
+ _autoHeartbeat = false;
+ }
+
+ ///
+ /// Initiates a graceful disconnect by completing the send pipe so the send loop drains remaining data before
+ /// issuing the TCP half-close.
+ ///
+ public void Disconnect()
+ {
+ try
+ {
+ if (_ioCancellation is null)
+ {
+ // Pipelines not started; fall back to immediate half-close.
+ Socket?.Shutdown(SocketShutdown.Send);
+ return;
+ }
+
+ // Completing the writer signals the send loop to drain then shut down.
+ _sendPipe.Writer.Complete();
+ }
+ catch (Exception)
+ {
+ Close();
+ }
+ }
+
+ /// true if the connection is active.
+ public bool IsConnected() => _currentState == State.Connected;
+
+ ///
+ /// Called when the connection is established.
+ ///
+ public void OnConnected()
+ {
+ _currentState = State.Connected;
+ _isClosed = 0;
+ _autoHeartbeat = true;
+ }
+
+ ///
+ /// Dispatches a fully assembled packet. Handles system protocol IDs and forwards application packets to the
+ /// registered .
+ ///
+ public void OnMessage(Packet message)
+ {
+ switch (message.ProtocolId)
+ {
+ case SYS_CLOSE_REQ:
+ Disconnect();
+ return;
+
+ case SYS_START_HEARTBEAT:
+ _ = message.PopProtocolId();
+ var interval = message.PopByte();
+ _heartbeatSender = new HeartbeatSender(this, interval);
+ if (_autoHeartbeat)
+ {
+ StartHeartbeat();
+ }
+
+ return;
+
+ case SYS_UPDATE_HEARTBEAT:
+ LatestHeartbeatTime = DateTime.Now.Ticks;
+ return;
+ }
+
+ if (Peer is not null)
+ {
+ try
+ {
+ switch (message.ProtocolId)
+ {
+ case SYS_CLOSE_ACK:
+ Peer.OnRemoved();
+ break;
+
+ default:
+ Peer.OnMessage(message);
+ break;
+ }
+ }
+ catch (Exception)
+ {
+ Close();
+ }
+ }
+
+ if (message.ProtocolId == SYS_CLOSE_ACK)
+ {
+ SessionClosed?.Invoke(this, new SessionEventArgs(this));
+ }
+ }
+
+ ///
+ /// Sends a packet.
+ ///
+ public void Send(Packet message)
+ {
+ message.RecordSize();
+ Send(new ArraySegment(message.Buffer ?? [], 0, message.Position));
+ }
+
+ ///
+ /// Sends a segment of bytes.
+ ///
+ public void Send(ArraySegment data)
+ {
+ try
+ {
+ var flushTask = _sendPipe.Writer.WriteAsync(
+ new ReadOnlyMemory(data.Array, data.Offset, data.Count));
+
+ if (!flushTask.IsCompletedSuccessfully)
+ {
+ _ = flushTask.AsTask();
+ }
+ }
+ catch (Exception)
+ {
+ Close();
+ }
+ }
+
+ ///
+ /// Starts the heartbeat sender.
+ ///
+ public void StartHeartbeat() => _heartbeatSender?.Play();
+
+ ///
+ /// Starts the pipelines asynchronously.
+ ///
+ /// A cancellation token.
+ public void StartPipelinesAsync(CancellationToken cancellationToken = default)
+ {
+ if (_ioCancellation is not null)
+ {
+ return;
+ }
+
+ _ioCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var ioToken = _ioCancellation.Token;
+
+ _receiveLoopTask = ReceiveLoopAsync(ioToken);
+ _sendLoopTask = SendLoopAsync(ioToken);
+ }
+
+ ///
+ /// Stops the heartbeat.
+ ///
+ public void StopHeartbeat() => _heartbeatSender?.Stop();
+
+ ///
+ /// Updates the heartbeat manually.
+ ///
+ /// The time.
+ public void UpdateHeartbeatManually(float time) => _heartbeatSender?.Update(time);
+
+ ///
+ /// Feeds raw bytes into the message resolver. Used by the receive loop and by unit tests to simulate incoming data
+ /// without a live socket.
+ ///
+ internal void OnReceive(byte[] buffer, int offset, int transferred) =>
+ _messageResolver.OnReceive(buffer, offset, transferred, OnMessageCompleted);
+
+ ///
+ /// Sends a SYS_CLOSE_REQ so the remote side closes first.
+ ///
+ private void ByeBye()
+ {
+ var bye = Packet.Create(SYS_CLOSE_REQ);
+ Send(bye);
+ }
+
+ private void OnMessageCompleted(ArraySegment buffer)
+ {
+ if (Peer is null)
+ {
+ return;
+ }
+
+ if (dispatcher is not null)
+ {
+ dispatcher.OnMessage(this, buffer);
+ }
+ else
+ {
+ Packet msg = new(buffer, this);
+ OnMessage(msg);
+ }
+ }
+
+ private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
+ {
+ if (Socket is null)
+ {
+ return;
+ }
+
+ var receiveBuffer = new byte[4096];
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested && Socket is not null)
+ {
+ int bytesReceived;
+ try
+ {
+ bytesReceived = await Socket
+ .ReceiveAsync(new Memory(receiveBuffer), SocketFlags.None, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (SocketException)
+ {
+ Close();
+ return;
+ }
+
+ if (bytesReceived == 0)
+ {
+ Close();
+ return;
+ }
+
+ OnReceive(receiveBuffer, 0, bytesReceived);
+ }
+ }
+ finally
+ {
+ Close();
+ }
+ }
+
+ private async Task SendLoopAsync(CancellationToken cancellationToken)
+ {
+ if (Socket is null)
+ {
+ return;
+ }
+
+ var reader = _sendPipe.Reader;
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested && Socket is not null)
+ {
+ ReadResult result;
+ try
+ {
+ result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ if (result.Buffer.Length > 0 && Socket is not null)
+ {
+ foreach (var segment in result.Buffer)
+ {
+ if (Socket is null)
+ {
+ break;
+ }
+
+ try
+ {
+ _ = await Socket
+ .SendAsync(segment, SocketFlags.None, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (SocketException)
+ {
+ Close();
+ return;
+ }
+ }
+ }
+
+ reader.AdvanceTo(result.Buffer.End);
+
+ if (result.IsCompleted)
+ {
+ try { Socket?.Shutdown(SocketShutdown.Send); }
+ catch (Exception) { }
+
+ break;
+ }
+ }
+ }
+ finally
+ {
+ await reader.CompleteAsync().ConfigureAwait(false);
+ Close();
+ }
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fe7e991
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 FreeNet Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Protocol.Tests/Protocol.Tests.csproj b/Protocol.Tests/Protocol.Tests.csproj
new file mode 100644
index 0000000..1f7d524
--- /dev/null
+++ b/Protocol.Tests/Protocol.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Protocol.Tests/ProtocolMessageTests.cs b/Protocol.Tests/ProtocolMessageTests.cs
new file mode 100644
index 0000000..339928b
--- /dev/null
+++ b/Protocol.Tests/ProtocolMessageTests.cs
@@ -0,0 +1,100 @@
+using FreeNet;
+
+namespace Protocol.Tests;
+
+public class ProtocolMessageTests
+{
+ [Test]
+ public async Task Packet_protocol_values_are_stable()
+ {
+ var begin = (short)PacketProtocol.BEGIN;
+ var chatReq = (short)PacketProtocol.CHAT_MSG_REQ;
+ var chatAck = (short)PacketProtocol.CHAT_MSG_ACK;
+ var moveReq = (short)PacketProtocol.MOVE_REQ;
+ var moveCast = (short)PacketProtocol.MOVE_CAST;
+ var userInfo = (short)PacketProtocol.USER_INFO;
+ var end = (short)PacketProtocol.END;
+
+ _ = await Assert.That(begin).IsEqualTo((short)0);
+ _ = await Assert.That(chatReq).IsEqualTo((short)1);
+ _ = await Assert.That(chatAck).IsEqualTo((short)2);
+ _ = await Assert.That(moveReq).IsEqualTo((short)3);
+ _ = await Assert.That(moveCast).IsEqualTo((short)4);
+ _ = await Assert.That(userInfo).IsEqualTo((short)5);
+ _ = await Assert.That(end).IsEqualTo((short)6);
+ }
+
+ [Test]
+ public async Task Packet_protocol_extension_round_trip_preserves_value()
+ {
+ const short rawProtocol = 4;
+ var typedProtocol = rawProtocol.ToProtocol();
+
+ _ = await Assert.That(typedProtocol).IsEqualTo(PacketProtocol.MOVE_CAST);
+ _ = await Assert.That(typedProtocol.ToShort()).IsEqualTo(rawProtocol);
+ }
+
+ [Test]
+ public async Task CSMoveReq_round_trip_preserves_payload()
+ {
+ var outbound = new MoveRequest
+ {
+ X = 1.25f,
+ Y = -2.5f,
+ Z = 10.75f,
+ Rotation = 270.0f,
+ };
+
+ var inbound = new MoveRequest(ToInboundPacket(outbound.ToPacket()));
+
+ _ = await Assert.That(inbound.X).IsEqualTo(outbound.X);
+ _ = await Assert.That(inbound.Y).IsEqualTo(outbound.Y);
+ _ = await Assert.That(inbound.Z).IsEqualTo(outbound.Z);
+ _ = await Assert.That(inbound.Rotation).IsEqualTo(outbound.Rotation);
+ }
+
+ [Test]
+ public async Task SCMoveCast_round_trip_preserves_payload()
+ {
+ var outbound = new MoveCast
+ {
+ UserID = 27,
+ X = 0.5f,
+ Y = 1.5f,
+ Z = 2.5f,
+ Rotation = 45.0f,
+ };
+
+ var inbound = new MoveCast(ToInboundPacket(outbound.ToPacket()));
+
+ _ = await Assert.That(inbound.UserID).IsEqualTo(outbound.UserID);
+ _ = await Assert.That(inbound.X).IsEqualTo(outbound.X);
+ _ = await Assert.That(inbound.Y).IsEqualTo(outbound.Y);
+ _ = await Assert.That(inbound.Z).IsEqualTo(outbound.Z);
+ _ = await Assert.That(inbound.Rotation).IsEqualTo(outbound.Rotation);
+ }
+
+ [Test]
+ public async Task SCUserInfo_round_trip_preserves_payload()
+ {
+ var outbound = new UserInfo
+ {
+ UserID = 123,
+ };
+
+ var inbound = new UserInfo(ToInboundPacket(outbound.ToPacket()));
+
+ _ = await Assert.That(inbound.UserID).IsEqualTo(outbound.UserID);
+ }
+
+ private static Packet ToInboundPacket(Packet outbound)
+ {
+ outbound.RecordSize();
+ var inbound = new Packet(new ArraySegment(outbound.Buffer, 0, outbound.Position), null!);
+
+ // Production flow consumes protocol id before message-specific parsing.
+ _ = inbound.PopProtocolId();
+
+ return inbound;
+ }
+}
diff --git a/Protocol/MoveCast.cs b/Protocol/MoveCast.cs
new file mode 100644
index 0000000..310e0a7
--- /dev/null
+++ b/Protocol/MoveCast.cs
@@ -0,0 +1,80 @@
+using FreeNet;
+
+namespace Protocol;
+
+///
+/// Represents a cast movement request in a networked application, containing user ID, position, and rotation data.
+/// Implements the .
+///
+///
+public class MoveCast : ProtocolMessage
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The message.
+ public MoveCast(Packet? message = null)
+ : base(PacketProtocol.MOVE_CAST)
+ {
+ if (message is not null)
+ {
+ _ = FromPacket(message);
+ }
+ }
+
+ ///
+ /// Gets or sets the rotation.
+ ///
+ /// The rotation.
+ public float Rotation { get; set; }
+
+ ///
+ /// Gets or sets the user identifier.
+ ///
+ /// The user identifier.
+ public short UserID { get; set; }
+
+ ///
+ /// Gets or sets the x.
+ ///
+ /// The x.
+ public float X { get; set; }
+
+ ///
+ /// Gets or sets the y.
+ ///
+ /// The y.
+ public float Y { get; set; }
+
+ ///
+ /// Gets or sets the z.
+ ///
+ /// The z.
+ public float Z { get; set; }
+
+ ///
+ public override MoveCast FromPacket(Packet msg)
+ {
+ UserID = msg.PopInt16();
+ X = msg.PopFloat();
+ Y = msg.PopFloat();
+ Z = msg.PopFloat();
+ Rotation = msg.PopFloat();
+ return this;
+ }
+
+ ///
+ public override Packet ToPacket()
+ {
+ var msg = Packet.Create(PacketProtocol.ToShort());
+ msg.Push(UserID);
+ msg.Push(X);
+ msg.Push(Y);
+ msg.Push(Z);
+ msg.Push(Rotation);
+ return msg;
+ }
+
+ ///
+ public override string ToString() => $"{PacketProtocol} User ({UserID}) Pos ({X}, {Y}, {Z}) rot : {Rotation} deg";
+}
diff --git a/Protocol/MoveRequest.cs b/Protocol/MoveRequest.cs
new file mode 100644
index 0000000..83aee81
--- /dev/null
+++ b/Protocol/MoveRequest.cs
@@ -0,0 +1,75 @@
+using FreeNet;
+
+namespace Protocol;
+
+///
+/// Represents a request to move an entity in a networked application, containing position and rotation data. Implements
+/// the
+///
+///
+public class MoveRequest : ProtocolMessage
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The message.
+ public MoveRequest(Packet? message = null)
+ : base(PacketProtocol.MOVE_REQ)
+ {
+ if (message is not null)
+ {
+ _ = FromPacket(message);
+ }
+ }
+
+ ///
+ /// Gets or sets the rotation.
+ ///
+ /// The rotation.
+ public float Rotation { get; set; }
+
+ ///
+ /// Gets or sets the x.
+ ///
+ /// The x.
+ public float X { get; set; }
+
+ ///
+ /// Gets or sets the y.
+ ///
+ /// The y.
+ public float Y { get; set; }
+
+ ///
+ /// Gets or sets the z.
+ ///
+ /// The z.
+ public float Z { get; set; }
+
+ ///
+ public override MoveRequest FromPacket(Packet msg)
+ {
+ X = msg.PopFloat();
+ Y = msg.PopFloat();
+ Z = msg.PopFloat();
+ Rotation = msg.PopFloat();
+
+ return this;
+ }
+
+ ///
+ public override Packet ToPacket()
+ {
+ var msg = Packet.Create(PacketProtocol.ToShort());
+
+ msg.Push(X);
+ msg.Push(Y);
+ msg.Push(Z);
+ msg.Push(Rotation);
+
+ return msg;
+ }
+
+ ///
+ public override string ToString() => $"{PacketProtocol} Pos ({X}, {Y}, {Z}) rot : {Rotation} deg";
+}
diff --git a/Protocol/PacketProtocol.cs b/Protocol/PacketProtocol.cs
new file mode 100644
index 0000000..aa51f7e
--- /dev/null
+++ b/Protocol/PacketProtocol.cs
@@ -0,0 +1,42 @@
+namespace Protocol;
+
+///
+/// PacketProtocol defines the protocol identifiers for different packet types used in the application.
+///
+public enum PacketProtocol : short
+{
+ ///
+ /// The beginning of the protocol identifiers.
+ ///
+ BEGIN = 0,
+
+ ///
+ /// Protocol identifier for chat message request.
+ ///
+ CHAT_MSG_REQ = 1,
+
+ ///
+ /// Protocol identifier for chat message acknowledgment.
+ ///
+ CHAT_MSG_ACK = 2,
+
+ ///
+ /// Protocol identifier for move request.
+ ///
+ MOVE_REQ = 3,
+
+ ///
+ /// Protocol identifier for move cast.
+ ///
+ MOVE_CAST = 4,
+
+ ///
+ /// Protocol identifier for user information.
+ ///
+ USER_INFO = 5,
+
+ ///
+ /// The end of the protocol identifiers.
+ ///
+ END,
+}
diff --git a/Protocol/PacketProtocolExtension.cs b/Protocol/PacketProtocolExtension.cs
new file mode 100644
index 0000000..5eed748
--- /dev/null
+++ b/Protocol/PacketProtocolExtension.cs
@@ -0,0 +1,21 @@
+namespace Protocol;
+
+///
+/// Extension methods for the PacketProtocol enum to facilitate conversion between short and PacketProtocol types.
+///
+public static class PacketProtocolExtension
+{
+ ///
+ /// Converts to protocol.
+ ///
+ /// The protocol.
+ /// PacketProtocol.
+ public static PacketProtocol ToProtocol(this short protocol) => (PacketProtocol)protocol;
+
+ ///
+ /// Converts to short.
+ ///
+ /// The protocol.
+ /// short.
+ public static short ToShort(this PacketProtocol protocol) => (short)protocol;
+}
diff --git a/Protocol/Protocol.csproj b/Protocol/Protocol.csproj
new file mode 100644
index 0000000..2192aa3
--- /dev/null
+++ b/Protocol/Protocol.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Protocol/ProtocolMessage.cs b/Protocol/ProtocolMessage.cs
new file mode 100644
index 0000000..12346f4
--- /dev/null
+++ b/Protocol/ProtocolMessage.cs
@@ -0,0 +1,30 @@
+using FreeNet;
+
+namespace Protocol;
+
+///
+/// Represents an abstract base class for protocol messages.
+///
+/// The type of the protocol message that inherits from this base class.
+/// The protocol.
+public abstract class ProtocolMessage(PacketProtocol protocol) where T : ProtocolMessage
+{
+ ///
+ /// Gets the packet protocol.
+ ///
+ /// The packet protocol.
+ protected PacketProtocol PacketProtocol { get; } = protocol;
+
+ ///
+ /// Froms the packet.
+ ///
+ /// The MSG.
+ /// T.
+ public abstract T FromPacket(Packet msg);
+
+ ///
+ /// Converts to packet.
+ ///
+ /// Packet.
+ public abstract Packet ToPacket();
+}
diff --git a/Protocol/UserInfo.cs b/Protocol/UserInfo.cs
new file mode 100644
index 0000000..a5aefe1
--- /dev/null
+++ b/Protocol/UserInfo.cs
@@ -0,0 +1,44 @@
+using FreeNet;
+
+namespace Protocol;
+
+///
+/// Represents user information. Implements the
+///
+///
+public class UserInfo : ProtocolMessage
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The message.
+ public UserInfo(Packet? message = null)
+ : base(PacketProtocol.USER_INFO)
+ {
+ if (message is not null)
+ {
+ _ = FromPacket(message);
+ }
+ }
+
+ ///
+ /// Gets or sets the user identifier.
+ ///
+ /// The user identifier.
+ public short UserID { get; set; }
+
+ ///
+ public override UserInfo FromPacket(Packet msg)
+ {
+ UserID = msg.PopInt16();
+ return this;
+ }
+
+ ///
+ public override Packet ToPacket()
+ {
+ var ret = Packet.Create(PacketProtocol.ToShort());
+ ret.Push(UserID);
+ return ret;
+ }
+}
diff --git a/README.md b/README.md
index cf870ef..2c370f4 100644
--- a/README.md
+++ b/README.md
@@ -1,53 +1,91 @@
-FreeNet
-=========
-C# Network library. Asynchronous. TCP. GameServer.
-
-Version
-----------
-* v0.1.0 Heartbeat
-* v0.0.1
-
-프로젝트 정보
-----------
-* C# 비동기 네트워크 라이브러리.
-* 게임 서버에서 사용할 수 있는 TCP기반의 socket server.
-* .Net Framework 3.5 사용
-* Unity 연동 가능
-
-Project info
-----------
-* C# Asynchronous network library.
-* TCP socket server that can be used in game server.
-* .Net Framework 3.5
-* Available in unity3d.
-
-Sample Game
-----------
-
-* FreeNet라이브러리를 활용하여 Unity로 만든 온라인 멀티플레이 보드 게임 세균전.
-* The VirusWar that online multiplay board game sample developed using FreeNet and Unity.
-
-아키텍처 및 구조
-----------
-
-
-
-
-
-
-Structure
-----------
-
-
-
-
-
-
-
-라이선스
-----------
-* 소스코드는 상업적, 비상업적 어느 용도이든 자유롭게 사용 가능 합니다.
-
-License
-----------
-* All source codes are free to use(Commercial use is possible).
+# FreeNet
+
+FreeNet is a lightweight asynchronous C# network library.
+
+---
+
+## Project Info
+
+* C# asynchronous network library.
+* TCP socket server that can be used in game servers.
+* Uses .NET 10
+* Can be integrated with Unity if built with .NetFramework
+
+---
+
+## Fork Changes
+
+* Added character movement packets and support for .NET 10
+* Unity project repository: [https://github.com/NyanReal/unitymobclient](https://github.com/NyanReal/unitymobclient)
+
+---
+
+## Contact
+
+* Email me if you have any questions : lee.seokhyun@gmail.com
+
+---
+
+## Version
+
+* **v0.2.0** - System.IO.Pipelines modernization
+ * Added modern async I/O via `UserToken.cs` for improved performance and resource efficiency
+ * Added `StartPipelinesAsync(CancellationToken)` and pipeline-backed `Send(...)` processing
+ * See [PIPELINES_MODERNIZATION.md](documents/PIPELINES_MODERNIZATION.md) for migration details
+ * Released with MIT License
+
+* **v0.1.2** - Upgrade to .NET 10
+ * Updated all projects to target .NET 10 runtime
+ * Enhanced async/await patterns and modern C# language features
+
+* **v0.1.1** - Apply .NET Core
+ * Migrated from .NET Framework to .NET Core
+
+* **v0.1.0** - Heartbeat support
+ * Added heartbeat mechanism for connection health monitoring
+
+* **v0.0.1** - Initial Release
+ * Initial async TCP socket server implementation with SAEA pooling
+
+### Semantic Versioning
+
+* FreeNet follows [SemVer](https://semver.org/) (`MAJOR.MINOR.PATCH`).
+* Build outputs automatically include generated semantic versions:
+ * `Release` builds: `MAJOR.MINOR.PATCH`
+ * non-`Release` builds: `MAJOR.MINOR.PATCH-dev.`
+
+---
+
+## License
+
+FreeNet is released under the [MIT License](LICENSE). The source code can be freely used for both commercial and non-commercial purposes.
+
+See the [LICENSE](LICENSE) file for the full text of the license.
+
+---
+
+## Structure
+
+* Transmission Method
+ * Asynchronous accept.
+ * Asynchronous receive and send.
+* Pooling
+ * SocketAsyncEventArgs pooling management.
+ * Receive buffer pooling management.
+* Performance Optimization
+ * Aggregate BufferList for batch sending.
+ * 
+ * Use of double buffering queues.
+* Thread Model
+ * IO thread packet processing method.
+ * Single logic thread packet processing method.
+* Additional Features
+ * Heartbeat functionality.
+
+
+
+
+
+
+
+---
diff --git a/TestManual.md b/TestManual.md
new file mode 100644
index 0000000..c5442dc
--- /dev/null
+++ b/TestManual.md
@@ -0,0 +1,34 @@
+# Test Manual
+
+## Test Client
+
+* Uses the test tool published on the cgcii website.
+* Download page: [http://www.cgcii.co.kr/index.php?mid=board_eLHH13&document_srl=1936](http://www.cgcii.co.kr/index.php?mid=board_eLHH13&document_srl=1936)
+* Test client download link: [http://www.cgcii.co.kr/?module=file&act=procFileDownload&file_srl=2976&sid=ed18a57f286b4fd7490ebd0fc2da9dcd&module_srl=1910](http://www.cgcii.co.kr/?module=file&act=procFileDownload&file_srl=2976&sid=ed18a57f286b4fd7490ebd0fc2da9dcd&module_srl=1910)
+* Test server: [http://www.cgcii.co.kr/?module=file&act=procFileDownload&file_srl=2976&sid=ed18a57f286b4fd7490ebd0fc2da9dcd&module_srl=1910](http://www.cgcii.co.kr/?module=file&act=procFileDownload&file_srl=2976&sid=ed18a57f286b4fd7490ebd0fc2da9dcd&module_srl=1910)
+
+---
+
+## Running a Test
+
+* Use the CSampleServer project included in the project.
+ Modify the code for testing
+ * Turn off heartbeat: The test client doesn't have a heartbeat feature, so you need to turn it off on the server.
+ * Remove the comment on line 29 of CSampleServer/Program.cs to call CNetworkService.disable_heartbeat().
+ * Enable echo server feature: Activate the echo server feature to send back the packets that the test client sends.
+ * Remove the comments on lines 52 and 53 of CSampleServer/CGameUser.cs.
+
+1. Run the test server and the test client.
+2. Carry out the test in the order shown in the picture.
+
+ 
+
+3. Test performance by increasing the Times entries (**Caution: if increased too much, your PC may crash!!**).
+---
+
+## Sample Game
+
+---
+
+
+* VirusWar is an online multiplayer board game sample developed using FreeNet and Unity.
diff --git a/documents/PIPELINES_MODERNIZATION.md b/documents/PIPELINES_MODERNIZATION.md
new file mode 100644
index 0000000..9eacda2
--- /dev/null
+++ b/documents/PIPELINES_MODERNIZATION.md
@@ -0,0 +1,49 @@
+# System.IO.Pipelines Modernization (v0.2.0)
+
+## Overview
+
+FreeNet v0.2.0 modernized transport I/O around `System.IO.Pipelines`. The core send/receive flow now runs on asynchronous pipeline loops while preserving the existing packet framing model (`HEADERSIZE = 4`) and application protocol handling.
+
+## Implemented Changes in v0.2.0
+
+### 1. Pipeline-driven connection startup
+
+`NetworkService` now starts per-connection pipeline loops when a socket connects:
+
+- `UserToken.StartPipelinesAsync(CancellationToken cancellationToken = default)`
+- Called for both accepted server connections and connected client sockets
+
+### 2. Send path moved to a `Pipe`
+
+`UserToken.Send(...)` writes outbound bytes into an internal `PipeWriter` (`_sendPipe.Writer`). A background send loop drains the pipe and performs `Socket.SendAsync(...)` calls.
+
+### 3. Dedicated async receive/send loops
+
+`UserToken` runs two tasks:
+
+- `ReceiveLoopAsync(...)`: receives socket data and forwards bytes to `MessageResolver`
+- `SendLoopAsync(...)`: flushes buffered outbound segments from the pipe to the socket
+
+### 4. Graceful shutdown semantics
+
+`Disconnect()` completes the pipe writer so pending sends can drain before TCP half-close (`SocketShutdown.Send`), while `Close()` still handles immediate teardown and session cleanup.
+
+## Backward Compatibility Notes
+
+- Message parsing and dispatch contracts are unchanged (`Packet`, `MessageResolver`, `IPeer`).
+- Protocol IDs for system behavior remain reserved (`<= 0`).
+- Existing app-level packet handlers continue to work without protocol changes.
+
+## Related repository updates
+
+The v0.2.0 repository updates also include:
+
+- Version metadata updated to `0.2.0` (`VersionPrefix`, `AssemblyVersion`, `FileVersion`)
+- `LICENSE` file added with the MIT license
+- `README.md` version and license sections updated
+
+## References
+
+- [README - Version](../README.md#version)
+- [README - Structure](../README.md#structure)
+- [Microsoft Learn: System.IO.Pipelines](https://learn.microsoft.com/en-us/dotnet/standard/io/pipelines)
diff --git a/favorites.json b/favorites.json
new file mode 100644
index 0000000..54c7b28
--- /dev/null
+++ b/favorites.json
@@ -0,0 +1,13 @@
+{
+ "version": 2,
+ "items": [
+ {
+ "name": ".editorconfig",
+ "path": ".editorconfig"
+ },
+ {
+ "name": "README.md",
+ "path": "README.md"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..3140116
--- /dev/null
+++ b/global.json
@@ -0,0 +1,5 @@
+{
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
diff --git a/skills/README.md b/skills/README.md
new file mode 100644
index 0000000..31a1440
--- /dev/null
+++ b/skills/README.md
@@ -0,0 +1,21 @@
+# Local skills
+
+This repository includes local, reusable `SKILL.md` files under `skills/`.
+
+These are adapted for FreeNet from public skill catalogs:
+- `github/awesome-copilot` (MIT)
+- `Aaronontheweb/dotnet-skills` (MIT)
+- `anthropics/skills` (reference/adaptation)
+
+## Included skills
+
+- `skills/acquire-codebase-knowledge/SKILL.md`
+- `skills/csharp-concurrency-patterns/SKILL.md`
+- `skills/dotnet-project-structure/SKILL.md`
+- `skills/mcp-builder/SKILL.md`
+
+## Notes
+
+- Skills are intentionally short and repo-oriented.
+- Prefer these over generic prompts when changing packet flow, threading, solution structure, or MCP setup in this repo.
+- For tool choices (CLI/MCP/etc.), prefer whichever option minimizes token usage first.
diff --git a/skills/acquire-codebase-knowledge/SKILL.md b/skills/acquire-codebase-knowledge/SKILL.md
new file mode 100644
index 0000000..a5c20fa
--- /dev/null
+++ b/skills/acquire-codebase-knowledge/SKILL.md
@@ -0,0 +1,53 @@
+---
+name: acquire-codebase-knowledge
+description: Use when mapping or documenting this repository at architecture level. Trigger for onboarding, architecture docs, or "understand this repo" requests.
+license: MIT-inspired adaptation from github/awesome-copilot
+---
+
+# Acquire Codebase Knowledge (FreeNet edition)
+
+Produce concise, evidence-backed documentation for this repository without guessing.
+
+## Required outputs
+
+Create/update these documents in `docs/codebase/`:
+1. `STACK.md`
+2. `STRUCTURE.md`
+3. `ARCHITECTURE.md`
+4. `CONVENTIONS.md`
+5. `TESTING.md`
+6. `CONCERNS.md`
+
+Every non-trivial claim must cite file paths.
+
+## Investigation sequence
+
+1. Read `README.md`, `TestManual.md`, `.github/copilot-instructions.md`, and `AGENTS.md`.
+2. Map solutions and projects:
+ - `FreeNet.slnx`
+ - `viruswar/server/viruswar_server.slnx`
+ - `*.csproj` under `FreeNet/`, `CSampleServer/`, `CSampleClient/`, `viruswar/server/GameServer/`
+3. Map runtime architecture from:
+ - `FreeNet/CNetworkService.cs`
+ - `FreeNet/CUserToken.cs`
+ - `FreeNet/CMessageResolver.cs`
+ - `FreeNet/CLogicMessageEntry.cs`
+ - `FreeNet/CListener.cs`
+4. Capture protocol/threading conventions from:
+ - `FreeNet/CPacket.cs`
+ - `FreeNet/IPeer.cs`
+ - sample/game `protocol.cs` files
+5. Document testing reality from available manual test flow and runnable sample apps.
+
+## FreeNet-specific checks
+
+- Confirm packet framing assumptions (`Defines.HEADERSIZE`, `record_size()` usage).
+- Confirm thread mode semantics (`new CNetworkService(true|false)`).
+- Confirm protocol ID reservation (`<= 0` system flow).
+- Confirm any client/server protocol divergence.
+
+## Guardrails
+
+- Do not invent CI/test/lint pipelines.
+- Mark unknowns as `[TODO]`.
+- Use `[ASK USER]` only for intent decisions that code cannot answer.
diff --git a/skills/csharp-concurrency-patterns/SKILL.md b/skills/csharp-concurrency-patterns/SKILL.md
new file mode 100644
index 0000000..281f852
--- /dev/null
+++ b/skills/csharp-concurrency-patterns/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: csharp-concurrency-patterns
+description: Use when changing threading, dispatch, receive/send flow, session lifecycle, or shared state handling in FreeNet.
+license: MIT-inspired adaptation from Aaronontheweb/dotnet-skills
+---
+
+# C# Concurrency Patterns for FreeNet
+
+Use this when code touches:
+- `CNetworkService`
+- `CUserToken`
+- `CLogicMessageEntry`
+- `CListener`
+- shared collections in sample/game server code
+
+## Decision guide
+
+1. **Need deterministic single-threaded message handling?**
+ - Keep or switch to `new CNetworkService(true)` and route through logic queue.
+2. **Need highest throughput with IO-thread handlers?**
+ - Keep `new CNetworkService(false)` and make shared-state synchronization explicit.
+3. **Touching user/session collections?**
+ - Preserve locking around shared `List` and callback paths.
+4. **Touching send queue behavior?**
+ - Preserve `sending_list` lock semantics and BufferList batching assumptions.
+
+## Rules to preserve
+
+- Never mix assumptions between logic-thread and IO-thread modes.
+- Keep `session_created_callback` safe for concurrent invocation.
+- Do not add blocking waits (`.Result`, `.Wait()`) to hot paths.
+- Preserve ordered packet parsing semantics in `CPacket`/`CMessageResolver`.
+
+## Review checklist
+
+- Any new mutable shared state has explicit synchronization.
+- No deadlock-prone lock ordering introduced.
+- Receive/send callback error behavior remains explicit.
+- Logic-thread mode still serializes packet processing end-to-end.
diff --git a/skills/dotnet-project-structure/SKILL.md b/skills/dotnet-project-structure/SKILL.md
new file mode 100644
index 0000000..8acf2ab
--- /dev/null
+++ b/skills/dotnet-project-structure/SKILL.md
@@ -0,0 +1,31 @@
+---
+name: dotnet-project-structure
+description: Use when adding, splitting, or wiring .NET projects/solutions in this repository.
+license: MIT-inspired adaptation from Aaronontheweb/dotnet-skills
+---
+
+# .NET Project Structure (FreeNet)
+
+This repo has two solution entry points:
+- `FreeNet.slnx` (library + sample client/server + VirusWar server project)
+- `viruswar/server/viruswar_server.slnx` (VirusWar server focused)
+
+## Structure constraints
+
+- `FreeNet/` is the reusable networking library (`net10.0`).
+- `CSampleServer/` and `CSampleClient/` are executable integration samples (`net10.0`) referencing `FreeNet`.
+- `viruswar/server/GameServer/` references `FreeNet` and is included in both architectural flows.
+
+## Change patterns
+
+1. **Adding a new executable sample**
+ - Use `net10.0`, add `ProjectReference` to `..\FreeNet\FreeNet.csproj`, include in `FreeNet.slnx`.
+2. **Adding core library APIs**
+ - Keep in `FreeNet/` and avoid coupling to sample app directories.
+3. **Adding protocol features**
+ - Align enum/message handling on both sender and receiver projects.
+
+## Validation baseline
+
+- Build `.\FreeNet.slnx` for broad compatibility.
+- Build `.\viruswar\server\viruswar_server.slnx` when touching VirusWar paths.
diff --git a/skills/mcp-builder/SKILL.md b/skills/mcp-builder/SKILL.md
new file mode 100644
index 0000000..ea0acac
--- /dev/null
+++ b/skills/mcp-builder/SKILL.md
@@ -0,0 +1,49 @@
+---
+name: mcp-builder
+description: Use when adding or improving MCP server configs/tools for this repository workflow.
+license: Adapted from anthropics/skills mcp-builder guidance
+---
+
+# MCP Builder (Repo workflow edition)
+
+Use for:
+- creating MCP config files for local/dev clients
+- adding GitHub/filesystem/playwright-style MCP servers
+- designing tool schemas and usage guidance for coding agents
+
+## Token-efficiency rule
+
+Choose the method that minimizes total token usage first.
+
+- Prefer concise, direct operations with smallest useful output.
+- Use CLI, MCP, or built-in tools based on which is likely to return the least context for the task.
+- If two options are equivalent in result quality, choose the lower-token path.
+
+## Process
+
+1. **Define workflows first**
+ - Decide what tasks must be enabled (code search, PR/issue ops, local file operations, browser testing).
+2. **Choose least-privilege server set**
+ - Keep filesystem roots scoped to repo.
+ - Keep token/env names explicit.
+3. **Make configs portable**
+ - Provide an example file (for this repo: `.mcp.json.example`).
+ - Document required env vars and setup steps.
+4. **Document usage with real tasks**
+ - Include where each server helps in this repo.
+
+## Tool design rules
+
+- Use clear, action-oriented tool names.
+- Support pagination/filtering on list-like operations.
+- Return concise, structured data where possible.
+- Surface actionable errors (missing auth, missing env, bad path scope).
+
+## FreeNet defaults
+
+- Use token-efficient workflows by default:
+ - scope queries narrowly
+ - request only required fields/results
+ - avoid broad scans when targeted lookup is possible
+- Keep MCP configs as optional integrations.
+- If MCP is used, keep filesystem MCP rooted to this repository path.
diff --git a/viruswar/client/Assets/FreeNet/CFreeNetEventManager.cs b/viruswar/client/Assets/FreeNet/CFreeNetEventManager.cs
index 41ac47b..bb893af 100644
--- a/viruswar/client/Assets/FreeNet/CFreeNetEventManager.cs
+++ b/viruswar/client/Assets/FreeNet/CFreeNetEventManager.cs
@@ -7,29 +7,30 @@ namespace FreeNetUnity
{
public enum NETWORK_EVENT : byte
{
- // 접속 완료.
+ // Connection established.
connected,
- // 연결 끊김.
+ // Connection disconnected.
disconnected,
- // 끝.
+ // End.
end
}
///
- /// 네트워크 엔진에서 발생된 이벤트들을 큐잉시킨다.
- /// 워커 스레드와 메인 스레드 양쪽에서 호출될 수 있으므로 스레드 동기화 처리를 적용하였다.
+ /// Queues events generated by the network engine.
+ /// This class can be called from both worker threads and the main thread,
+ /// so thread synchronization is applied.
///
public class CFreeNetEventManager
{
- // 동기화 객체.
+ // Synchronization object.
object cs_event;
- // 네트워크 엔진에서 발생된 이벤트들을 보관해놓는 큐.
+ // Queue to store events generated by the network engine.
Queue network_events;
- // 서버에서 받은 패킷들을 보관해놓는 큐.
+ // Queue to store packets received from the server.
Queue network_message_events;
public CFreeNetEventManager()
@@ -88,4 +89,4 @@ public CPacket dequeue_network_message()
}
}
}
-}
\ No newline at end of file
+}
diff --git a/viruswar/client/Assets/FreeNet/CFreeNetUnityService.cs b/viruswar/client/Assets/FreeNet/CFreeNetUnityService.cs
index a365545..c11e21c 100644
--- a/viruswar/client/Assets/FreeNet/CFreeNetUnityService.cs
+++ b/viruswar/client/Assets/FreeNet/CFreeNetUnityService.cs
@@ -9,26 +9,28 @@
namespace FreeNetUnity
{
///
- /// FreeNet엔진과 유니티 어플리케이션을 이어주는 클래스이다.
- /// FreeNet엔진에서 받은 접속 이벤트, 메시지 수신 이벤트등을 어플리케이션으로 전달하는 역할을 하는데
- /// MonoBehaviour를 상속받아 유니티 어플리케이션과 동일한 스레드에서 작동되도록 구현하였다.
- /// 따라서 이 클래스의 콜백 매소드에서 유니티 오브젝트에 접근할 때 별도의 동기화 처리는 하지 않아도 된다.
+ /// This class bridges the FreeNet engine and Unity application.
+ /// It receives connection events and message reception events from the FreeNet engine
+ /// and forwards them to the application. It inherits from MonoBehaviour and is implemented
+ /// to operate on the same thread as the Unity application.
+ /// Therefore, no additional synchronization is needed when accessing Unity objects
+ /// in this class's callback methods.
///
public class CFreeNetUnityService : MonoBehaviour
{
CFreeNetEventManager event_manager;
- // 연결된 게임 서버 객체.
+ // Connected game server object.
IPeer gameserver;
- // TCP통신을 위한 서비스 객체.
+ // Service object for TCP communication.
CNetworkService service;
- // 접속 완료시 호출되는 델리게이트. 어플리케이션에서 콜백 매소드를 설정하여 사용한다.
+ // Delegate called when connection is established. The application sets a callback method to use.
public delegate void StatusChangedHandler(NETWORK_EVENT status);
public StatusChangedHandler appcallback_on_status_changed;
- // 네트워크 메시지 수신시 호출되는 델리게이트. 어플리케이션에서 콜백 매소드를 설정하여 사용한다.
+ // Delegate called when network message is received. The application sets a callback method to use.
public delegate void MessageHandler(CPacket msg);
public MessageHandler appcallback_on_message;
@@ -39,15 +41,15 @@ void Awake()
public void connect(string host, int port)
{
- if (this.service == null)
- {
- // CNetworkService객체는 메시지의 비동기 송,수신 처리를 수행한다.
- this.service = new CNetworkService();
- }
+ if (this.service == null)
+ {
+ // CNetworkService object handles asynchronous message send/receive processing.
+ this.service = new CNetworkService();
+ }
- // endpoint정보를 갖고있는 Connector생성. 만들어둔 NetworkService객체를 넣어준다.
+ // Create a Connector with endpoint information. Pass the NetworkService object created above.
CConnector connector = new CConnector(service);
- // 접속 성공시 호출될 콜백 매소드 지정.
+ // Specify the callback method to be called when connection is successful.
connector.connected_callback += on_connected_gameserver;
IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse(host), port);
connector.connect(endpoint);
@@ -61,7 +63,7 @@ public bool is_connected()
///
- /// 접속 성공시 호출될 콜백 매소드.
+ /// Callback method called when connection is successful.
///
///
void on_connected_gameserver(CUserToken server_token)
@@ -69,21 +71,22 @@ void on_connected_gameserver(CUserToken server_token)
this.gameserver = new CRemoteServerPeer(server_token);
((CRemoteServerPeer)this.gameserver).set_eventmanager(this.event_manager);
- // Update매소드에서 직접 보낼것이기 때문에 엔진에서 보내는 하트비트는 끈다.
- server_token.disable_auto_heartbeat();
+ // Disable heartbeat from the engine since it will be sent directly from Update method.
+ server_token.disable_auto_heartbeat();
- // 유니티 어플리케이션으로 이벤트를 넘겨주기 위해서 매니저에 큐잉 시켜 준다.
- this.event_manager.enqueue_network_event(NETWORK_EVENT.connected);
- }
+ // Queue the event to the manager to pass it to the Unity application.
+ this.event_manager.enqueue_network_event(NETWORK_EVENT.connected);
+ }
///
- /// 네트워크에서 발생하는 모든 이벤트를 클라이언트에게 알려주는 역할을 Update에서 진행한다.
- /// FreeNet엔진의 메시지 송수신 처리는 워커스레드에서 수행되지만 유니티의 로직 처리는 메인 스레드에서 수행되므로
- /// 큐잉처리를 통하여 메인 스레드에서 모든 로직 처리가 이루어지도록 구성하였다.
+ /// All network events are reported to the client in the Update method.
+ /// Message send/receive processing in the FreeNet engine is performed on worker threads,
+ /// but logic processing in Unity is performed on the main thread,
+ /// so through queuing, all logic processing is performed on the main thread.
///
void Update()
{
- // 수신된 메시지에 대한 콜백.
+ // Callback for received messages.
if (this.event_manager.has_message())
{
CPacket msg = this.event_manager.dequeue_network_message();
@@ -93,23 +96,23 @@ void Update()
}
}
- // 네트워크 발생 이벤트에 대한 콜백.
- if (this.event_manager.has_event())
+ // Callback for network events.
+ if (this.event_manager.has_event())
{
- NETWORK_EVENT status = this.event_manager.dequeue_network_event();
- on_status_changed(status);
+ NETWORK_EVENT status = this.event_manager.dequeue_network_event();
+ on_status_changed(status);
if (this.appcallback_on_status_changed != null)
{
this.appcallback_on_status_changed(status);
}
}
- // heartbeat.
- if (this.gameserver != null)
- {
- ((CRemoteServerPeer)this.gameserver).update_heartbeat(Time.deltaTime);
- }
- }
+ // Heartbeat.
+ if (this.gameserver != null)
+ {
+ ((CRemoteServerPeer)this.gameserver).update_heartbeat(Time.deltaTime);
+ }
+ }
void on_status_changed(NETWORK_EVENT status)
@@ -136,7 +139,8 @@ public void send(CPacket msg)
}
///
- /// 정상적인 종료시에는 OnApplicationQuit매소드에서 disconnect를 호출해 줘야 유니티가 hang되지 않는다.
+ /// On normal shutdown, disconnect must be called from the OnApplicationQuit method
+ /// to prevent Unity from hanging.
///
void OnApplicationQuit()
{
diff --git a/viruswar/client/Assets/FreeNet/CRemoteServerPeer.cs b/viruswar/client/Assets/FreeNet/CRemoteServerPeer.cs
index 9d80b61..99ac293 100644
--- a/viruswar/client/Assets/FreeNet/CRemoteServerPeer.cs
+++ b/viruswar/client/Assets/FreeNet/CRemoteServerPeer.cs
@@ -29,7 +29,7 @@ public void update_heartbeat(float time)
}
///
- /// 메시지를 수신했을 때 호출된다.
+ /// Called when a message is received.
///
void IPeer.on_message(CPacket msg)
{
diff --git a/viruswar/client/Assets/scripts/State/IStateObjectGenerationType.cs b/viruswar/client/Assets/scripts/State/IStateObjectGenerationType.cs
index eceacb1..f84f6ed 100644
--- a/viruswar/client/Assets/scripts/State/IStateObjectGenerationType.cs
+++ b/viruswar/client/Assets/scripts/State/IStateObjectGenerationType.cs
@@ -4,16 +4,16 @@
public enum STATE_OBJECT_TYPE
{
- // 자신의 오브젝트에 모든 스테이트 스크립트를 attach하는 형태.
+ // Type that attaches all state scripts to the same object.
ATTACH_TO_SINGLE_OBJECT,
- // 새로운 게임 오브젝트를 생성하고 child로 붙이는 형태.
+ // Type that creates new game objects and attaches them as children.
CREATE_NEW_OBJECT
}
///
-/// 스테이트 생성 방식에 따른 분류.
+/// Classification based on state generation method.
///
public interface IStateObjectGenerationType
{
diff --git a/viruswar/client/Assets/viruswar/scripts/CNetworkManager.cs b/viruswar/client/Assets/viruswar/scripts/CNetworkManager.cs
index c82082a..a2868a0 100644
--- a/viruswar/client/Assets/viruswar/scripts/CNetworkManager.cs
+++ b/viruswar/client/Assets/viruswar/scripts/CNetworkManager.cs
@@ -35,7 +35,7 @@ void Awake()
public void connect()
{
- // 이전에 보내지 못한 패킷은 모두 버린다.
+ // Discard all packets that were not sent previously.
this.sending_queue.Clear();
if (!this.freenet.is_connected())
diff --git a/viruswar/client/Assets/viruswar/scripts/CPlayerRenderer.cs b/viruswar/client/Assets/viruswar/scripts/CPlayerRenderer.cs
index 8cb861d..d81825a 100644
--- a/viruswar/client/Assets/viruswar/scripts/CPlayerRenderer.cs
+++ b/viruswar/client/Assets/viruswar/scripts/CPlayerRenderer.cs
@@ -3,7 +3,7 @@
using UnityEngine;
///
-/// 플레이어의 현재 상태를 렌더링 하는 역할을 담당.
+/// Responsible for rendering the player's current state.
///
public class CPlayerRenderer : MonoBehaviour {
@@ -48,17 +48,14 @@ public void clear()
public void add(short position)
{
// Create an instance.
- // 바이러스 인스턴스 생성.
GameObject clone = CGameWorld.Instance.instantiate(this.prefab_character);
clone.transform.parent = transform;
// Set position.
- // 좌표 설정.
Vector2 map_position = CHelper.convert_to_position(position);
clone.transform.localPosition = CHelper.map_to_world(map_position);
// Set default state.
- // 상태 설정.
CVirus virus = clone.GetComponent();
virus.update_position(position);
virus.idle();
@@ -72,7 +69,7 @@ public void remove(short position)
CVirus virus = this.viruses.Find(v => v.is_same(position));
if (virus == null)
{
- // null이면 안되는데??
+ // Should not be null
Debug.LogErrorFormat("Cannot find a virus of the position. position : {0}", position);
return;
}
@@ -85,8 +82,6 @@ public void remove(short position)
///
/// Makes all viruses touchable.
- ///
- /// 모든 바이러스들을 터치 가능한 상태로 만든다.
///
public void ready()
{
@@ -99,8 +94,6 @@ public void ready()
///
/// Makes all viruses untouchable.
- ///
- /// 모든 바이러스들을 터치 불가능한 상태로 만든다.
///
public void idle()
{
diff --git a/viruswar/client/Assets/viruswar/scripts/effect/CScaleController.cs b/viruswar/client/Assets/viruswar/scripts/effect/CScaleController.cs
index 6b2bdc7..8af9441 100644
--- a/viruswar/client/Assets/viruswar/scripts/effect/CScaleController.cs
+++ b/viruswar/client/Assets/viruswar/scripts/effect/CScaleController.cs
@@ -27,7 +27,7 @@ void LateUpdate()
{
this.time += Time.deltaTime;
- // 모바일(안드로이드)에서 안먹어서 Vector3.Lerp로 교체함. 원인은 아직 모름.
+ // Replaced with Vector3.Lerp as it doesn't work on mobile (Android). Cause is still unknown.
//transform.localScale = easing_vector3(this.scale_from, this.scale_to, this.time / this.duration, EasingUtil.easeInQuad);
transform.localScale = Vector3.Lerp(this.scale_from, this.scale_to, this.time / this.duration);
}
diff --git a/viruswar/client/Assets/viruswar/scripts/play/CBorderViewer.cs b/viruswar/client/Assets/viruswar/scripts/play/CBorderViewer.cs
index 6e2d16d..0564a75 100644
--- a/viruswar/client/Assets/viruswar/scripts/play/CBorderViewer.cs
+++ b/viruswar/client/Assets/viruswar/scripts/play/CBorderViewer.cs
@@ -4,7 +4,6 @@
///
/// This class shows borders of movable cells for a player.
-/// 이 클래스는 캐릭터가 이동할 수 있는 셀을 보여주는 기능을 한다.
///
public class CBorderViewer : MonoBehaviour {
@@ -21,10 +20,10 @@ void Awake()
void load()
{
- // 하나의 객체가 이동 할 수 있는 최대 영역.
+ // Maximum area a single object can move.
const int MAX_MOVABLE_CELL_COUNT = 24;
- // 리소스 로딩.
+ // Load resources.
this.borders.Clear();
GameObject source = Resources.Load("prefabs/border") as GameObject;
for (int i = 0; i < MAX_MOVABLE_CELL_COUNT; ++i)
@@ -49,20 +48,20 @@ public void show(short center, List targets)
{
for (int i = 0; i < targets.Count; ++i)
{
- // 맵 좌표를 월드 좌표로 변환하여 트랜스폼에 적용시킨다.
+ // Convert map coordinates to world coordinates and apply to transform.
Vector3 pos = CHelper.map_to_world(CHelper.convert_to_position(targets[i]));
this.borders[i].transform.position = pos;
this.borders[i].SetActive(true);
if (CHelper.howfar_from_clicked_cell(center, targets[i]) <= 1)
{
- // 한칸 떨어진 곳을 표시할 이미지.
+ // Image to mark cells one cell away.
this.borders[i].transform.FindChild("copy").gameObject.SetActive(true);
this.borders[i].transform.FindChild("move").gameObject.SetActive(false);
}
else
{
- // 두칸 떨어진 곳을 표시할 이미지.
+ // Image to mark cells two cells away.
this.borders[i].transform.FindChild("move").gameObject.SetActive(true);
this.borders[i].transform.FindChild("copy").gameObject.SetActive(false);
}
diff --git a/viruswar/client/Assets/viruswar/scripts/play/CGameWorld.cs b/viruswar/client/Assets/viruswar/scripts/play/CGameWorld.cs
index 2e2b342..416876a 100644
--- a/viruswar/client/Assets/viruswar/scripts/play/CGameWorld.cs
+++ b/viruswar/client/Assets/viruswar/scripts/play/CGameWorld.cs
@@ -3,19 +3,19 @@
using UnityEngine;
///
-/// 게임 객체들을 품고 있는 월드 객체.
+/// World object that contains game objects.
///
public class CGameWorld : CSingletonMonobehaviour
{
///
- /// 월드내에 객체를 생성한다.
+ /// Creates an object within the world.
///
///
///
public GameObject instantiate(GameObject obj)
{
- // 객체 생성시 CGameWorld하위로 오도록 만든다.
- // 어떤 오브젝트가 어디에 있는지 디버깅하기 쉬우라고 이렇게 했음.
+ // When creating an object, make it a child of CGameWorld.
+ // This was done to make it easier to debug where objects are located.
GameObject clone = GameObject.Instantiate(obj);
clone.transform.parent = transform;
return clone;
diff --git a/viruswar/client/Assets/viruswar/scripts/play/CVirus.cs b/viruswar/client/Assets/viruswar/scripts/play/CVirus.cs
index 401b551..e394fbe 100644
--- a/viruswar/client/Assets/viruswar/scripts/play/CVirus.cs
+++ b/viruswar/client/Assets/viruswar/scripts/play/CVirus.cs
@@ -3,11 +3,11 @@
using UnityEngine;
///
-/// 바이러스 객체.
+/// Virus object.
///
public class CVirus : MonoBehaviour {
- // 맵 포지션.
+ // Map position.
public short cell { get; private set; }
GameObject appear;
@@ -16,11 +16,11 @@ public class CVirus : MonoBehaviour {
void Awake()
{
- // 생성될 때 사용할 오브젝트.
+ // Object to use when created.
this.appear = transform.FindChild("appear").gameObject;
this.appear.SetActive(false);
- // 사라질 때 사용할 오브젝트.
+ // Object to use when disappearing.
this.disappear = transform.FindChild("destroy").gameObject;
this.disappear.SetActive(false);
}
@@ -33,21 +33,21 @@ public void update_position(short cell)
///
- /// 대기 상태로 만든다.
+ /// Sets the idle state.
///
public void idle()
{
- // 터치 불가능 하게 한다.
+ // Make it untouchable.
GetComponent().enabled = false;
this.appear.SetActive(true);
- // 모션을 멈춘다.
+ // Stop the animation.
this.appear.GetComponent().stop();
}
///
- /// 터치 가능한 상태로 만든다.
+ /// Makes it touchable.
///
public void touchable()
{
@@ -56,7 +56,7 @@ public void touchable()
///
- /// 삭제 한다.
+ /// Deletes this virus.
///
public void destroy()
{
@@ -67,7 +67,7 @@ public void destroy()
public void on_touch()
{
- // 좌, 우로 흔들거리는 모습 재생.
+ // Play swaying left and right animation.
this.appear.GetComponent().play();
}
diff --git a/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomGameOverState.cs b/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomGameOverState.cs
index 3c7542b..6be90d3 100644
--- a/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomGameOverState.cs
+++ b/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomGameOverState.cs
@@ -5,7 +5,7 @@
using GameServer;
///
-/// 게임이 종료된 상태.
+/// Game over state.
///
public class CBattleRoomGameOverState : MonoBehaviour, IState
{
@@ -48,7 +48,7 @@ void Update()
return;
}
- // 종료 팝업 출력.
+ // Show quit popup.
CUIManager.Instance.show(UI_PAGE.POPUP_QUIT);
CPopupQuit popup =
CUIManager.Instance.get_uipage(UI_PAGE.POPUP_QUIT).GetComponent();
diff --git a/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomReadyState.cs b/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomReadyState.cs
index 1cffcb4..92f0d72 100644
--- a/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomReadyState.cs
+++ b/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomReadyState.cs
@@ -5,7 +5,7 @@
using GameServer;
///
-/// 첫번째 턴 시작 전 대기 상태.
+/// Waiting state before the first turn starts.
///
public class CBattleRoomReadyState : MonoBehaviour, IState
{
@@ -38,12 +38,10 @@ void make_touchable_buttons()
GameObject clone = CGameWorld.Instance.instantiate(source);
// Convert map position to world position.
- // 맵 좌표를 월드 좌표로 변환한다.
Vector2 map_position = new Vector3(j, i);
clone.transform.localPosition = CHelper.map_to_world(map_position);
- // Set button index to find which button is touched.
- // 어느 버튼을 눌렀는지 구별하기 위한 인덱스를 저장한다.
+ // Save the index to distinguish which button was pressed.
clone.AddComponent().set(index);
++index;
}
diff --git a/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomTurnPlayingState.cs b/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomTurnPlayingState.cs
index 73ae496..7350d72 100644
--- a/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomTurnPlayingState.cs
+++ b/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomTurnPlayingState.cs
@@ -5,18 +5,16 @@
using GameServer;
///
-/// 내 턴이 진행중인 상태.
+/// State when my turn is in progress.
///
public class CBattleRoomTurnPlayingState : MonoBehaviour, IState
{
CBattleRoom room;
// The player's character position that selected.
- // 선택한 캐릭터의 위치.
short selected_character_position = short.MaxValue;
// A Board data contains indexes from 0 to 49.
- // 0~49까지의 인덱스를 갖고 있는 보드판 데이터.
List table_board;
@@ -25,7 +23,6 @@ void Awake()
this.room = GetComponent();
// Make board data.
- // 보드판 데이터를 만든다.
this.table_board = new List();
for (int i = 0; i < CBattleRoom.COL_COUNT * CBattleRoom.COL_COUNT; ++i)
{
@@ -33,11 +30,9 @@ void Awake()
}
// Enable touches.
- // 터치 활성화.
gameObject.AddComponent();
// A component to see movable area.
- // 이동 가능한 영역을 보여주기 위한 컴포넌트.
gameObject.AddComponent();
}
@@ -60,14 +55,12 @@ void ready_to_select()
GetComponent().hide();
// Enable collision check.
- // 충돌 기능 활성화.
gameObject.GetComponent().enabled = true;
// Stop effects.
this.room.get_players().ForEach(player => player.GetComponent().stop());
// Enable viruses touch if my turn playing.
- // 내 턴일 경우 바이러스들의 터치를 활성화 한다.
if (this.room.is_my_turn())
{
this.room.get_current_player().GetComponent().ready();
@@ -77,13 +70,11 @@ void ready_to_select()
///
/// Called when collision area touched.
- /// 충돌영역 어딘가에 터치 이벤트가 발생 했을 때.
///
///
void on_touch_collision_area(GameObject target)
{
// When touched a character.
- // 캐릭터를 터치했을 때.
CVirus virus = target.GetComponent();
if (virus != null)
{
@@ -97,7 +88,6 @@ void on_touch_collision_area(GameObject target)
}
// When touched an empty cell.
- // 빈 셀을 터치했을 때.
CButtonAction cell = target.GetComponent();
if (cell != null)
{
@@ -119,13 +109,11 @@ void show_movable_area(short center)
///
/// When touched cell area.
- /// 셀 영역을 터치 했을 때.
///
///
void on_cell_touch(short cell)
{
// An opponent place can not be touched.
- // 상대방이 있는 자리는 터치할 수 없다.
foreach (CPlayer player in this.room.get_players())
{
if (player.cell_indexes.Exists(obj => obj == cell))
@@ -134,8 +122,7 @@ void on_cell_touch(short cell)
}
}
- // A distance over two space can not be moved.
- // 2칸을 초과하는 거리는 이동할 수 없다.
+ // A distance over two spaces can not be moved.
if (CHelper.get_distance(this.selected_character_position, cell) > 2)
{
return;
@@ -144,7 +131,6 @@ void on_cell_touch(short cell)
GetComponent().hide();
// Send moving packet.
- // 이동 패킷 전송.
CPacket msg = CPacket.create((short)PROTOCOL.MOVING_REQ);
msg.push(this.selected_character_position);
msg.push(cell);
diff --git a/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomWaitState.cs b/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomWaitState.cs
index 3725ecf..52a09ac 100644
--- a/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomWaitState.cs
+++ b/viruswar/client/Assets/viruswar/scripts/play/room_state/CBattleRoomWaitState.cs
@@ -3,7 +3,7 @@
using UnityEngine;
///
-/// 상대방 턴이 진행중이라 대기중인 상태.
+/// Waiting state because opponent's turn is in progress.
///
public class CBattleRoomWaitState : MonoBehaviour, IState
{
diff --git a/viruswar/client/Assets/viruswar/scripts/protocol.cs b/viruswar/client/Assets/viruswar/scripts/protocol.cs
index 822bd7a..47a5f8a 100644
--- a/viruswar/client/Assets/viruswar/scripts/protocol.cs
+++ b/viruswar/client/Assets/viruswar/scripts/protocol.cs
@@ -3,60 +3,60 @@
namespace GameServer
{
///
- /// 프로토콜 정의.
- /// 서버에서 클라이언트로 가는 패킷 : S -> C
- /// 클라이언트에서 서버로 가는 패킷 : C -> S
+ /// Protocol definition.
+ /// Packets from server to client: S -> C
+ /// Packets from client to server: C -> S
///
public enum PROTOCOL : short
{
//-------------------------------------
- // 0 이하는 종료코드로 사용되므로 게임에서 쓰지 말것!!
+ // Do not use values <= 0 in the game; they are reserved for termination codes!!
//-------------------------------------
BEGIN = 0,
//-------------------------------------
- // 로비 프로토콜.
+ // Lobby protocol.
//-------------------------------------
- // C -> S 게임방 입장 요청.
+ // C -> S Request to enter game room.
ENTER_GAME_ROOM_REQ = 1,
- // S -> C 게임장 입장 요청에 대한 응답.
+ // S -> C Response to game room entry request.
ENTER_GAME_ROOM_ACK = 2,
- // S -> C 매칭이 성공했다. 방에 입장하고 로딩을 시작해라.
+ // S -> C Matching successful. Enter the room and start loading.
START_LOADING = 3,
- // 동시 접속자 정보 요청/응답.
+ // Concurrent user information request/response.
CONCURRENT_USERS = 4,
//-------------------------------------
- // 게임 프로토콜.
+ // Game protocol.
//-------------------------------------
- // C -> S 게임방 리소스 로딩을 완료했다. 게임을 시작해도 좋다.
+ // C -> S Game room resource loading is complete. OK to start the game.
READY_TO_START = 10,
- // 게임 시작.
+ // Game start.
GAME_START = 11,
- // 턴 시작.
+ // Turn start.
START_PLAYER_TURN = 12,
- // 클라이언트의 이동 요청.
+ // C -> S Client movement request.
MOVING_REQ = 13,
- // 플레이어가 이동 했음을 알린다.
+ // Player has moved.
PLAYER_MOVED = 14,
- // 클라이언트의 턴 연출이 끝났음을 알린다.
+ // C -> S Client turn animation is finished.
TURN_FINISHED_REQ = 15,
- // 게임 종료.
+ // Game over.
GAME_OVER = 16,
- // 방이 삭제됨.
+ // Room removed.
ROOM_REMOVED = 17,
END
diff --git a/viruswar/client/Assets/viruswar/scripts/public/EasingUtil.cs b/viruswar/client/Assets/viruswar/scripts/public/EasingUtil.cs
index f7156bf..cd1a870 100644
--- a/viruswar/client/Assets/viruswar/scripts/public/EasingUtil.cs
+++ b/viruswar/client/Assets/viruswar/scripts/public/EasingUtil.cs
@@ -2,8 +2,8 @@
using System.Collections;
///
-/// iTween에서 easing부분만 추려내어 만든 클래스.
-/// 사용법은 Mathf.Lerp와 같다. (시작값, 끝값, 시간값) 시간값은 0 ~ 1 사이.
+/// A class created by extracting only the easing part from iTween.
+/// Usage is the same as Mathf.Lerp (start value, end value, time value). Time value is between 0 ~ 1.
///
public static class EasingUtil
{
diff --git a/viruswar/client/Assets/viruswar/scripts/ui/CBattleRoom.cs b/viruswar/client/Assets/viruswar/scripts/ui/CBattleRoom.cs
index e380d35..5c3dce5 100644
--- a/viruswar/client/Assets/viruswar/scripts/ui/CBattleRoom.cs
+++ b/viruswar/client/Assets/viruswar/scripts/ui/CBattleRoom.cs
@@ -9,20 +9,19 @@ public class CBattleRoom : MonoBehaviour, IMessageReceiver {
//--------------------------------------------------
// Define state.
- // 상태 정의.
//--------------------------------------------------
public enum STATE
{
- // 게임 시작 전 준비 상태.
+ // Preparation state before game starts.
READY,
- // 내 턴이 진행중인 상태.
+ // State when my turn is in progress.
TURN_PLAYING,
- // 상대방 턴이 진행중인 상태.
+ // State when opponent's turn is in progress.
WAIT,
- // 게임이 끝난 상태.
+ // State when game is over.
GAMEOVER,
}
@@ -35,75 +34,68 @@ public enum MESSAGE
//--------------------------------------------------
// Reference data.
- // 참조용 데이터.
//--------------------------------------------------
- // 가로, 세로 칸 수를 의미한다.
+ // Horizontal and vertical cell count.
public static readonly int COL_COUNT = 7;
- //--------------------------------------------------
- // Game instances.
- // 게임 객체들.
- //--------------------------------------------------
- // 플레이어들.
- List players;
+ //--------------------------------------------------
+ // Game instances.
+ //--------------------------------------------------
+ // Players.
+ List players;
- // 점수등의 플레이어 정보.
- List players_gameinfo;
+ // Player information such as score.
+ List players_gameinfo;
- // 현재 턴을 진행중인 플레이어 인덱스.
+ // Index of the player whose turn is in progress.
byte current_player_index;
- // 서버에서 지정해준 본인의 플레이어 인덱스.
- byte player_me_index;
+ // The player index assigned by the server.
+ byte player_me_index;
- // 승리한 플레이어 인덱스.
- // 무승부일때는 byte.MaxValue가 들어간다.
- byte win_player_index;
+ // Index of the winning player.
+ // When it's a tie, byte.MaxValue is entered.
+ byte win_player_index;
- // 게임이 종료되었는지를 나타내는 플래그.
+ // Flag indicating whether the game is finished.
bool is_game_finished;
- // 상태 매니저.
- CStateManager state_manager;
+ // State manager.
+ CStateManager state_manager;
- void Awake()
+ void Awake()
{
- this.players = new List();
- this.players_gameinfo = new List();
-
- // 방의 각 상태를 담당하는 인스턴스 생성.
- this.state_manager = gameObject.AddComponent();
- this.state_manager.initialize(STATE_OBJECT_TYPE.ATTACH_TO_SINGLE_OBJECT);
- this.state_manager.add(STATE.READY);
- this.state_manager.add(STATE.WAIT);
- this.state_manager.add(STATE.TURN_PLAYING);
- this.state_manager.add(STATE.GAMEOVER);
-
- // 초기 상태 설정.
- this.state_manager.change_state(STATE.READY);
- }
+ this.players = new List();
+ this.players_gameinfo = new List();
+
+ // Create instances responsible for each state of the room.
+ this.state_manager = gameObject.AddComponent();
+ this.state_manager.initialize(STATE_OBJECT_TYPE.ATTACH_TO_SINGLE_OBJECT);
+ this.state_manager.add(STATE.READY);
+ this.state_manager.add(STATE.WAIT);
+ this.state_manager.add(STATE.TURN_PLAYING);
+ this.state_manager.add(STATE.GAMEOVER);
+
+ // Set initial state.
+ this.state_manager.change_state(STATE.READY);
+ }
///
/// Called when enter the game from client.
/// Load resources if you need.
- ///
- /// 게임방에 입장할 때 클라이언트에서 호출된다.
- /// 필요한 리소스가 있다면 여기서 로딩한다.
///
public void start_loading()
{
clear_before_start();
// From now on, this class instance will receives all network messages.
- // 네트워크에서 넘어온 메시지를 이 클래스 인스턴스가 받도록 설정한다.
CNetworkManager.Instance.message_receiver = this;
// Send ready.
- // 준비 완료 패킷 전송.
CPacket msg = CPacket.create((short)PROTOCOL.READY_TO_START);
CNetworkManager.Instance.send(msg);
}
@@ -142,56 +134,55 @@ void clear_before_start()
}
- ///
- /// Called when received packets.
- /// 패킷을 수신 했을 때 호출됨.
- ///
- ///
- ///
- void IMessageReceiver.on_recv(CPacket msg)
+ ///
+ /// Called when received packets.
+ ///
+ ///
+ ///
+ void IMessageReceiver.on_recv(CPacket msg)
{
PROTOCOL protocol_id = (PROTOCOL)msg.pop_protocol_id();
- // 동시접속자 정보가 아닌 다른 패킷일 수신했을 경우 WAIT팝업을 닫는다.
- if (protocol_id != PROTOCOL.CONCURRENT_USERS)
- {
- CUIManager.Instance.hide(UI_PAGE.POPUP_WAIT);
- }
+ // If a packet is received that is not concurrent user information, close the WAIT popup.
+ if (protocol_id != PROTOCOL.CONCURRENT_USERS)
+ {
+ CUIManager.Instance.hide(UI_PAGE.POPUP_WAIT);
+ }
switch (protocol_id)
{
- // 게임을 시작해라.
+ // Start the game.
case PROTOCOL.GAME_START:
on_game_start(msg);
break;
- // 플레이어가 이동 했다.
+ // Player has moved.
case PROTOCOL.PLAYER_MOVED:
on_player_moved(msg);
break;
- // 턴을 시작해라.
+ // Start turn.
case PROTOCOL.START_PLAYER_TURN:
on_start_player_turn(msg);
break;
- // 방이 삭제됐다. 누가 끊겼던지 강제종료 했던지 등등.
- case PROTOCOL.ROOM_REMOVED:
- on_room_removed();
- break;
+ // Room removed. Someone disconnected or was forcefully closed, etc.
+ case PROTOCOL.ROOM_REMOVED:
+ on_room_removed();
+ break;
- // 게임이 종료됐다.
- case PROTOCOL.GAME_OVER:
+ // Game is over.
+ case PROTOCOL.GAME_OVER:
on_game_over(msg);
break;
- case PROTOCOL.CONCURRENT_USERS:
- {
- int count = msg.pop_int32();
- CUIManager.Instance.get_uipage(UI_PAGE.STATUS_BAR).GetComponent().refresh(count);
- }
- break;
- }
+ case PROTOCOL.CONCURRENT_USERS:
+ {
+ int count = msg.pop_int32();
+ CUIManager.Instance.get_uipage(UI_PAGE.STATUS_BAR).GetComponent().refresh(count);
+ }
+ break;
+ }
}
@@ -208,7 +199,7 @@ void on_room_removed()
CUIManager.Instance.show(UI_PAGE.POPUP_COMMON);
CPopupCommon popup =
CUIManager.Instance.get_uipage(UI_PAGE.POPUP_COMMON).GetComponent();
- popup.refresh("상대방이 게임을 나갔습니다.", () => { back_to_main(); });
+ popup.refresh("Opponent has left the game.", () => { back_to_main(); });
}
diff --git a/viruswar/client/Assets/viruswar/scripts/ui/CMainMenu.cs b/viruswar/client/Assets/viruswar/scripts/ui/CMainMenu.cs
index 3de91b2..9368f24 100644
--- a/viruswar/client/Assets/viruswar/scripts/ui/CMainMenu.cs
+++ b/viruswar/client/Assets/viruswar/scripts/ui/CMainMenu.cs
@@ -41,7 +41,7 @@ void on_play()
CUIManager.Instance.show(UI_PAGE.POPUP_NETWORK_PROCESSING);
CPopupNetworkProcessing popup =
CUIManager.Instance.get_uipage(UI_PAGE.POPUP_NETWORK_PROCESSING).GetComponent();
- popup.refresh("서버에 접속중");
+ popup.refresh("Connecting to server");
this.network_manager.connect();
}
@@ -52,13 +52,13 @@ void on_play()
///
- /// 패킷을 수신 했을 때 호출됨.
+ /// Called when a packet is received.
///
///
///
void IMessageReceiver.on_recv(CPacket msg)
{
- // 제일 먼저 프로토콜 아이디를 꺼내온다.
+ // First, extract the protocol ID.
PROTOCOL protocol_id = (PROTOCOL)msg.pop_protocol_id();
switch (protocol_id)
@@ -69,7 +69,7 @@ void IMessageReceiver.on_recv(CPacket msg)
CUIManager.Instance.show(UI_PAGE.POPUP_NETWORK_PROCESSING);
CPopupNetworkProcessing popup =
CUIManager.Instance.get_uipage(UI_PAGE.POPUP_NETWORK_PROCESSING).GetComponent();
- popup.refresh("매칭 대기중");
+ popup.refresh("Waiting for match");
CUIManager.Instance.show(UI_PAGE.STATUS_BAR);
CUIManager.Instance.get_uipage(UI_PAGE.STATUS_BAR).GetComponent().refresh(1);
diff --git a/viruswar/client/Assets/viruswar/scripts/ui/CPopupResult.cs b/viruswar/client/Assets/viruswar/scripts/ui/CPopupResult.cs
index 4752983..9ed8521 100644
--- a/viruswar/client/Assets/viruswar/scripts/ui/CPopupResult.cs
+++ b/viruswar/client/Assets/viruswar/scripts/ui/CPopupResult.cs
@@ -33,19 +33,19 @@ public void refresh(byte win_player_index, byte player_me_index)
{
if (win_player_index == byte.MaxValue)
{
- // draw.
- this.txt_result.text = "무승부";
+ // Draw.
+ this.txt_result.text = "Draw";
}
else
{
bool win = win_player_index == player_me_index;
if (win)
{
- this.txt_result.text = "승리!!";
+ this.txt_result.text = "Victory!!";
}
else
{
- this.txt_result.text = "패배...";
+ this.txt_result.text = "Defeat...";
}
}
diff --git a/viruswar/server/GameServer.Tests/CHelperTests.cs b/viruswar/server/GameServer.Tests/CHelperTests.cs
new file mode 100644
index 0000000..0626d43
--- /dev/null
+++ b/viruswar/server/GameServer.Tests/CHelperTests.cs
@@ -0,0 +1,20 @@
+namespace GameServer.Tests;
+
+public class CHelperTests
+{
+ [Test]
+ public async Task Distance_between_adjacent_cells_is_one()
+ {
+ var distance = Helper.GetDistance(0, 1);
+
+ _ = await Assert.That(distance).IsEqualTo((short)1);
+ }
+
+ [Test]
+ public async Task Get_position_maps_row_and_col()
+ {
+ var position = Helper.GetPosition(2, 3);
+
+ _ = await Assert.That(position).IsEqualTo((short)17);
+ }
+}
diff --git a/viruswar/server/GameServer.Tests/GameRoomPlayStateTests.cs b/viruswar/server/GameServer.Tests/GameRoomPlayStateTests.cs
new file mode 100644
index 0000000..128eda4
--- /dev/null
+++ b/viruswar/server/GameServer.Tests/GameRoomPlayStateTests.cs
@@ -0,0 +1,92 @@
+using System.Net.Sockets;
+using System.Reflection;
+using FreeNet;
+using GameServer.RoomState;
+
+namespace GameServer.Tests;
+
+public class GameRoomPlayStateTests
+{
+ [Test]
+ public async Task Infect_converts_neighbor_viruses_from_victim_to_attacker()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var attacker = CreatePlayer(0);
+ var victim = CreatePlayer(1);
+ room.GetPlayers().Add(attacker);
+ room.GetPlayers().Add(victim);
+
+ var state = new GameRoomPlayState(room);
+
+ InvokePrivate(state, "PutVirus", (byte)0, (short)8);
+ InvokePrivate(state, "PutVirus", (byte)1, (short)9);
+ InvokePrivate(state, "PutVirus", (byte)1, (short)16);
+
+ state.Infect(8, attacker, victim);
+
+ _ = await Assert.That(attacker.Viruses.Contains((short)9)).IsTrue();
+ _ = await Assert.That(attacker.Viruses.Contains((short)16)).IsTrue();
+ _ = await Assert.That(victim.Viruses.Contains((short)9)).IsFalse();
+ _ = await Assert.That(victim.Viruses.Contains((short)16)).IsFalse();
+ }
+
+ [Test]
+ public async Task TurnFinished_returns_early_when_not_all_clients_reported()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ room.GetPlayers().Add(CreatePlayer(0));
+ room.GetPlayers().Add(CreatePlayer(1));
+
+ var state = new GameRoomPlayState(room);
+ var packet = Packet.Create((short)PROTOCOL.TURN_FINISHED_REQ);
+ packet.RecordSize();
+ var received = new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), null!);
+
+ state.TurnFinished(room.GetPlayer(0), received);
+
+ _ = await Assert.That(room.GetCurrentPlayer().PlayerIndex).IsEqualTo((byte)0);
+ }
+
+ [Test]
+ public async Task MovingReq_rejects_non_current_player()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ room.GetPlayers().Add(CreatePlayer(0));
+ room.GetPlayers().Add(CreatePlayer(1));
+
+ var state = new GameRoomPlayState(room);
+ InvokePrivate(state, "PutVirus", (byte)0, (short)0);
+
+ var request = Packet.Create((short)PROTOCOL.MOVING_REQ);
+ request.Push((short)0);
+ request.Push((short)1);
+ request.RecordSize();
+
+ var incoming = new Packet(new ArraySegment(request.Buffer, 0, request.Position), null!);
+ state.MovingReq(room.GetPlayer(1), incoming);
+
+ _ = await Assert.That(room.GetPlayer(1).Viruses.Count).IsEqualTo(0);
+ }
+
+ private static void InvokePrivate(GameRoomPlayState state, string method, byte playerIndex, short position)
+ {
+ var target = typeof(GameRoomPlayState).GetMethod(
+ method,
+ BindingFlags.Instance | BindingFlags.NonPublic,
+ binder: null,
+ types: [typeof(byte), typeof(short)],
+ modifiers: null) ?? throw new InvalidOperationException($"Missing method: {method}");
+ _ = target.Invoke(state, [playerIndex, position]);
+ }
+
+ private static Player CreatePlayer(byte index)
+ {
+ var token = new UserToken(null!)
+ {
+ Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
+ };
+
+ var user = new GameUser(token);
+ return new Player(user, index);
+ }
+}
diff --git a/viruswar/server/GameServer.Tests/GameRoomTests.cs b/viruswar/server/GameServer.Tests/GameRoomTests.cs
new file mode 100644
index 0000000..1abab47
--- /dev/null
+++ b/viruswar/server/GameServer.Tests/GameRoomTests.cs
@@ -0,0 +1,268 @@
+using System.Net.Sockets;
+using System.Reflection;
+using FreeNet;
+
+namespace GameServer.Tests;
+
+public class GameRoomTests
+{
+ // ------------------------------------------------------------------ GameRoom --
+
+ [Test]
+ public async Task EnterGameRoom_throws_for_null_player1()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var threw = false;
+ try { room.EnterGameRoom(null!, CreatePlayer(1)); }
+ catch (Exception) { threw = true; }
+
+ _ = await Assert.That(threw).IsTrue();
+ }
+
+ [Test]
+ public async Task EnterGameRoom_throws_for_null_player2()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var threw = false;
+ try { room.EnterGameRoom(CreatePlayer(0), null!); }
+ catch (Exception) { threw = true; }
+
+ _ = await Assert.That(threw).IsTrue();
+ }
+
+ [Test]
+ public async Task EnterGameRoom_throws_when_room_already_has_two_players()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ room.GetPlayers().Add(CreatePlayer(0));
+ room.GetPlayers().Add(CreatePlayer(1));
+
+ var threw = false;
+ try { room.EnterGameRoom(CreatePlayer(0), CreatePlayer(1)); }
+ catch (Exception) { threw = true; }
+
+ _ = await Assert.That(threw).IsTrue();
+ }
+
+ [Test]
+ public async Task EnterGameRoom_adds_players_before_broadcasting()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ // Players with real sockets so StartSend fails gracefully instead of NRE
+ try { room.EnterGameRoom(CreateBoundPlayer(0), CreateBoundPlayer(1)); }
+ catch { /* broadcast fails on unconnected sockets – expected */ }
+
+ _ = await Assert.That(room.GetPlayerCount()).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task GetOpponentPlayer_returns_the_other_player()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var p1 = CreatePlayer(0);
+ var p2 = CreatePlayer(1);
+ room.GetPlayers().Add(p1);
+ room.GetPlayers().Add(p2);
+
+ _ = await Assert.That(ReferenceEquals(room.GetOpponentPlayer(p1), p2)).IsTrue();
+ _ = await Assert.That(ReferenceEquals(room.GetOpponentPlayer(p2), p1)).IsTrue();
+ }
+
+ [Test]
+ public async Task GetOpponentPlayer_overload_uses_current_player()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var p1 = CreatePlayer(0);
+ var p2 = CreatePlayer(1);
+ room.GetPlayers().Add(p1);
+ room.GetPlayers().Add(p2);
+
+ _ = await Assert.That(ReferenceEquals(room.GetOpponentPlayer(), p2)).IsTrue();
+ }
+
+ [Test]
+ public async Task IsCurrentPlayer_returns_true_only_for_turn_player()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var p1 = CreatePlayer(0);
+ var p2 = CreatePlayer(1);
+ room.GetPlayers().Add(p1);
+ room.GetPlayers().Add(p2);
+
+ _ = await Assert.That(room.IsCurrentPlayer(p1)).IsTrue();
+ _ = await Assert.That(room.IsCurrentPlayer(p2)).IsFalse();
+ }
+
+ [Test]
+ public async Task EachPlayer_invokes_action_for_all_players()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ room.GetPlayers().Add(CreatePlayer(0));
+ room.GetPlayers().Add(CreatePlayer(1));
+
+ var count = 0;
+ room.EachPlayer(_ => count++);
+
+ _ = await Assert.That(count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task GetPlayer_returns_correct_player_at_index()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var p1 = CreatePlayer(0);
+ var p2 = CreatePlayer(1);
+ room.GetPlayers().Add(p1);
+ room.GetPlayers().Add(p2);
+
+ _ = await Assert.That(ReferenceEquals(room.GetPlayer(0), p1)).IsTrue();
+ _ = await Assert.That(ReferenceEquals(room.GetPlayer(1), p2)).IsTrue();
+ _ = await Assert.That(room.GetPlayerCount()).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task AllReceived_returns_true_when_all_players_sent_same_protocol()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var p1 = CreateBoundPlayer(0);
+ var p2 = CreateBoundPlayer(1);
+ room.GetPlayers().Add(p1);
+ room.GetPlayers().Add(p2);
+
+ var bytes = BuildPacket((short)PROTOCOL.MOVING_REQ);
+ room.OnReceive(p1, new Packet(new ArraySegment(bytes, 0, bytes.Length), null!));
+ room.OnReceive(p2, new Packet(new ArraySegment(bytes, 0, bytes.Length), null!));
+
+ _ = await Assert.That(room.AllReceived(PROTOCOL.MOVING_REQ)).IsTrue();
+ _ = await Assert.That(room.AllReceived(PROTOCOL.MOVING_REQ)).IsFalse(); // cleared
+ }
+
+ [Test]
+ public async Task AllReceived_returns_false_when_protocols_differ()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var p1 = CreatePlayer(0);
+ var p2 = CreatePlayer(1);
+ room.GetPlayers().Add(p1);
+ room.GetPlayers().Add(p2);
+
+ var readyBytes = BuildPacket((short)PROTOCOL.READY_TO_START);
+ var otherBytes = BuildPacket((short)PROTOCOL.TURN_FINISHED_REQ);
+
+ room.OnReceive(p1, new Packet(new ArraySegment(readyBytes, 0, readyBytes.Length), null!));
+ room.OnReceive(p2, new Packet(new ArraySegment(otherBytes, 0, otherBytes.Length), null!));
+
+ _ = await Assert.That(room.AllReceived(PROTOCOL.READY_TO_START)).IsFalse();
+ }
+
+ [Test]
+ public async Task OnReceive_ignores_duplicate_protocol_from_same_player()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var p1 = CreatePlayer(0);
+ room.GetPlayers().Add(p1);
+
+ var bytes = BuildPacket((short)PROTOCOL.MOVING_REQ);
+ room.OnReceive(p1, new Packet(new ArraySegment(bytes, 0, bytes.Length), null!));
+ room.OnReceive(p1, new Packet(new ArraySegment(bytes, 0, bytes.Length), null!));
+
+ _ = await Assert.That(room.AllReceived(PROTOCOL.MOVING_REQ)).IsTrue();
+ }
+
+ // --------------------------------------------------------------- Player --
+
+ [Test]
+ public async Task Player_add_remove_cell_get_virus_count_and_reset_work()
+ {
+ var token = new UserToken(null!);
+ var user = new GameUser(token);
+ var player = new Player(user, 2);
+
+ player.AddCell(10);
+ player.AddCell(20);
+ _ = await Assert.That(player.GetVirusCount()).IsEqualTo(2);
+
+ player.RemoveCell(10);
+ _ = await Assert.That(player.GetVirusCount()).IsEqualTo(1);
+ _ = await Assert.That(player.Viruses.Contains((short)20)).IsTrue();
+
+ player.Reset();
+ _ = await Assert.That(player.GetVirusCount()).IsEqualTo(0);
+ }
+
+ // ------------------------------------------------------- GameServerImpl --
+
+ [Test]
+ public async Task GameServerImpl_matchingreq_adds_first_user_to_waiting_list()
+ {
+ var impl = new GameServerImpl();
+ var user = CreateUserWithBoundToken();
+
+ try { impl.MatchingReq(user); } catch { /* send failure on unconnected socket */ }
+
+ _ = await Assert.That(GetWaitingList(impl).Contains(user)).IsTrue();
+ }
+
+ [Test]
+ public async Task GameServerImpl_matchingreq_does_not_add_duplicate_user()
+ {
+ var impl = new GameServerImpl();
+ var user = CreateUserWithBoundToken();
+ GetWaitingList(impl).Add(user); // pre-add
+
+ try { impl.MatchingReq(user); } catch { }
+
+ _ = await Assert.That(GetWaitingList(impl).Count).IsEqualTo(1);
+ }
+
+ // ---------------------------------------------------------------- GameUser --
+
+ [Test]
+ public async Task GameUser_change_state_and_enter_room_work()
+ {
+ var token = new UserToken(null!);
+ var user = new GameUser(token);
+
+ user.EnterRoom(new GameRoom(new GameRoomManager()), 0);
+
+ _ = await Assert.That(user.Player is not null).IsTrue();
+ _ = await Assert.That(user.Player?.PlayerIndex).IsEqualTo((byte)0);
+ }
+
+ // ---------------------------------------------------------------- helpers --
+
+ private static Player CreatePlayer(byte index)
+ {
+ var token = new UserToken(null!);
+ return new Player(new GameUser(token), index);
+ }
+
+ private static Player CreateBoundPlayer(byte index) =>
+ new(CreateUserWithBoundToken(), index);
+
+ private static GameUser CreateUserWithBoundToken()
+ {
+ var token = new UserToken(null!)
+ {
+ Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
+ };
+ return new GameUser(token);
+ }
+
+ private static byte[] BuildPacket(short protocol)
+ {
+ var p = Packet.Create(protocol);
+ p.RecordSize();
+ var bytes = new byte[p.Position];
+ Array.Copy(p.Buffer, bytes, p.Position);
+ return bytes;
+ }
+
+ private static List GetWaitingList(GameServerImpl impl)
+ {
+ var field = typeof(GameServerImpl).GetField(
+ "_matchingWaitingUsers",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ return (List)field!.GetValue(impl)!;
+ }
+}
diff --git a/viruswar/server/GameServer.Tests/GameServer.Tests.csproj b/viruswar/server/GameServer.Tests/GameServer.Tests.csproj
new file mode 100644
index 0000000..be8e586
--- /dev/null
+++ b/viruswar/server/GameServer.Tests/GameServer.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/viruswar/server/GameServer.Tests/GameServerUtilityTests.cs b/viruswar/server/GameServer.Tests/GameServerUtilityTests.cs
new file mode 100644
index 0000000..7b5ccd6
--- /dev/null
+++ b/viruswar/server/GameServer.Tests/GameServerUtilityTests.cs
@@ -0,0 +1,236 @@
+using FreeNet;
+using GameServer.State;
+using System.Numerics;
+
+namespace GameServer.Tests;
+
+public class GameServerUtilityTests
+{
+ private enum TestStateKey
+ {
+ Lobby,
+ Play
+ }
+
+ private enum TestMessageKey
+ {
+ Ping,
+ Pong
+ }
+
+ [Test]
+ public async Task MessageDispatcher_register_dispatch_overwrite_and_unregister_work()
+ {
+ var dispatcher = new MessageDispatcher();
+ var called = 0;
+ var lastPayload = string.Empty;
+
+ dispatcher.Register(TestMessageKey.Ping, (number, text) =>
+ {
+ called += number;
+ lastPayload = text;
+ });
+
+ dispatcher.Dispatch(TestMessageKey.Ping, 2, "first");
+ dispatcher.Register(TestMessageKey.Ping, (number, text) =>
+ {
+ called += number * 10;
+ lastPayload = text;
+ });
+
+ dispatcher.Dispatch(TestMessageKey.Ping, 1, "second");
+ dispatcher.Unregister(TestMessageKey.Ping);
+ dispatcher.Dispatch(TestMessageKey.Ping, 9, "ignored");
+
+ _ = await Assert.That(called).IsEqualTo(12);
+ _ = await Assert.That(lastPayload).IsEqualTo("second");
+ }
+
+ [Test]
+ public async Task StateManager_transitions_and_dispatches_only_for_current_state()
+ {
+ var manager = new StateManager();
+ var lobby = new FakeState();
+ var play = new FakeState();
+ var handled = 0;
+
+ manager.Add(TestStateKey.Lobby, lobby);
+ manager.Add(TestStateKey.Play, play);
+
+ manager.SendStateMessage(TestMessageKey.Ping, 1, "no state yet");
+
+ manager.RegisterMessageHandler(lobby, TestMessageKey.Ping, (number, _) => handled += number);
+ manager.ChangeState(TestStateKey.Lobby);
+ manager.SendStateMessage(TestMessageKey.Ping, 3, "lobby");
+
+ manager.ChangeState(TestStateKey.Play);
+ manager.SendStateMessage(TestMessageKey.Ping, 5, "play has no handler");
+
+ _ = await Assert.That(lobby.EnterCount).IsEqualTo(1);
+ _ = await Assert.That(lobby.ExitCount).IsEqualTo(1);
+ _ = await Assert.That(play.EnterCount).IsEqualTo(1);
+ _ = await Assert.That(handled).IsEqualTo(3);
+ _ = await Assert.That(manager.IsCurrentState(TestStateKey.Play)).IsTrue();
+ }
+
+ [Test]
+ public async Task StateManager_unregister_stops_future_dispatches()
+ {
+ var manager = new StateManager();
+ var state = new FakeState();
+ var count = 0;
+
+ manager.Add(TestStateKey.Lobby, state);
+ manager.RegisterMessageHandler(state, TestMessageKey.Pong, (left, right) => count += left + right);
+ manager.ChangeState(TestStateKey.Lobby);
+
+ manager.SendStateMessage(TestMessageKey.Pong, 2, 3);
+ manager.UnregisterMessageHandler(state, TestMessageKey.Pong);
+ manager.SendStateMessage(TestMessageKey.Pong, 7, 8);
+
+ _ = await Assert.That(count).IsEqualTo(5);
+ }
+
+ [Test]
+ public async Task Helper_calculates_coordinates_and_distances()
+ {
+ _ = await Assert.That(Helper.CalcRow(17)).IsEqualTo((short)2);
+ _ = await Assert.That(Helper.CalcColumn(17)).IsEqualTo((short)3);
+ _ = await Assert.That(Helper.GetPosition(6, 6)).IsEqualTo((short)48);
+ _ = await Assert.That(Helper.GetDistance(0, 8)).IsEqualTo((short)1);
+ _ = await Assert.That(Helper.HowFarFromClickedCell(0, 16)).IsEqualTo((byte)2);
+ }
+
+ [Test]
+ public async Task Helper_neighbor_and_available_cell_queries_filter_occupied_cells()
+ {
+ var allCells = new List { 0, 1, 2, 7, 8, 14 };
+ var playerA = CreatePlayer(0, 0, 1);
+ var playerB = CreatePlayer(1, 8);
+ var players = new List { playerA, playerB };
+
+ var neighbors = Helper.FindNeighborCells(0, allCells, 2);
+ var available = Helper.FindAvailableCells(0, allCells, players);
+
+ _ = await Assert.That(neighbors.Contains((short)14)).IsTrue();
+ _ = await Assert.That(available.Contains((short)1)).IsFalse();
+ _ = await Assert.That(available.Contains((short)8)).IsFalse();
+ _ = await Assert.That(available.Contains((short)2)).IsTrue();
+ }
+
+ [Test]
+ public async Task Helper_canplaymore_reflects_available_moves()
+ {
+ var board = Enumerable.Range(0, 49).Select(static n => (short)n).ToList();
+
+ var movable = CreatePlayer(0, 0);
+ var blockedCells = Enumerable.Range(1, 48).Select(static n => (short)n).ToArray();
+ var blockedOpponent = CreatePlayer(1, blockedCells);
+
+ var canContinue = Helper.CanPlayMore(board, movable, [movable, CreatePlayer(1, 48)]);
+ var cannotContinue = Helper.CanPlayMore(board, movable, [movable, blockedOpponent]);
+
+ _ = await Assert.That(canContinue).IsTrue();
+ _ = await Assert.That(cannotContinue).IsFalse();
+ }
+
+ [Test]
+ public async Task Helper_shuffle_preserves_all_elements()
+ {
+ var values = Enumerable.Range(1, 20).ToList();
+ Helper.Shuffle(values);
+ values.Sort();
+
+ _ = await Assert.That(values.SequenceEqual(Enumerable.Range(1, 20))).IsTrue();
+ }
+
+ [Test]
+ public async Task Vector2_subtraction_returns_axis_differences()
+ {
+ var result = new Vector2(6, 1) - new Vector2(2, 5);
+
+ _ = await Assert.That(result.X).IsEqualTo(4);
+ _ = await Assert.That(result.Y).IsEqualTo(-4);
+ }
+
+ [Test]
+ public async Task PlayerMovingData_initializes_positions_and_accelerations()
+ {
+ var data = new PlayerMovingData(2, 1.5f, 2.5f, 3.5f);
+
+ _ = await Assert.That(data.PlayerIndex).IsEqualTo((byte)2);
+ _ = await Assert.That(data.PositionX).IsEqualTo(1.5f);
+ _ = await Assert.That(data.PositionY).IsEqualTo(2.5f);
+ _ = await Assert.That(data.PositionZ).IsEqualTo(3.5f);
+ _ = await Assert.That(data.Accelerations.Count).IsEqualTo(Enum.GetValues().Length);
+ _ = await Assert.That(data.Accelerations.Values.All(static value => value == 0.0f)).IsTrue();
+ }
+
+ [Test]
+ public async Task GameRoom_onreceive_tracks_protocol_and_allreceived_resets_state()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var players = room.GetPlayers();
+ var owner = CreatePlayer(0, 0);
+ players.Add(owner);
+
+ var movingPacketBytes = BuildProtocolOnlyPacket((short)PROTOCOL.MOVING_REQ);
+ var packet = new Packet(new ArraySegment(movingPacketBytes, 0, movingPacketBytes.Length), null!);
+
+ room.OnReceive(owner, packet);
+
+ _ = await Assert.That(room.AllReceived(PROTOCOL.MOVING_REQ)).IsTrue();
+ _ = await Assert.That(room.AllReceived(PROTOCOL.MOVING_REQ)).IsFalse();
+ }
+
+ [Test]
+ public async Task GameRoom_turnnext_cycles_between_players()
+ {
+ var room = new GameRoom(new GameRoomManager());
+ var players = room.GetPlayers();
+ players.Add(CreatePlayer(0, 0));
+ players.Add(CreatePlayer(1, 48));
+
+ _ = await Assert.That(room.GetCurrentPlayer().PlayerIndex).IsEqualTo((byte)0);
+
+ room.TurnNext();
+ _ = await Assert.That(room.GetCurrentPlayer().PlayerIndex).IsEqualTo((byte)1);
+
+ room.TurnNext();
+ _ = await Assert.That(room.GetCurrentPlayer().PlayerIndex).IsEqualTo((byte)0);
+ }
+
+ private static byte[] BuildProtocolOnlyPacket(short protocol)
+ {
+ var packet = Packet.Create(protocol);
+ packet.RecordSize();
+
+ var copy = new byte[packet.Position];
+ Array.Copy(packet.Buffer, copy, packet.Position);
+ return copy;
+ }
+
+ private static Player CreatePlayer(byte index, params short[] viruses)
+ {
+ var token = new UserToken(null!);
+ var user = new GameUser(token);
+ var player = new Player(user, index);
+
+ foreach (var virus in viruses)
+ {
+ player.AddCell(virus);
+ }
+
+ return player;
+ }
+
+ private sealed class FakeState : IState
+ {
+ public int EnterCount { get; private set; }
+ public int ExitCount { get; private set; }
+
+ public void OnEnter() => EnterCount++;
+
+ public void OnExit() => ExitCount++;
+ }
+}
diff --git a/viruswar/server/GameServer.Tests/ProgramAndServerImplTests.cs b/viruswar/server/GameServer.Tests/ProgramAndServerImplTests.cs
new file mode 100644
index 0000000..f19c7c5
--- /dev/null
+++ b/viruswar/server/GameServer.Tests/ProgramAndServerImplTests.cs
@@ -0,0 +1,76 @@
+using System.Reflection;
+using FreeNet;
+using GameServer.UserState;
+
+namespace GameServer.Tests;
+
+public class ProgramAndServerImplTests
+{
+ [Test]
+ public async Task Program_session_callbacks_update_concurrent_user_count()
+ {
+ var token = new UserToken(null!);
+
+ Program.OnSessionCreated(null, new SessionEventArgs(token));
+ var user = GetLatestUser();
+
+ _ = await Assert.That(Program.GetConcurrentUserCount() > 0).IsTrue();
+
+ Program.RemoveUser(user);
+
+ _ = await Assert.That(Program.GetConcurrentUserCount() >= 0).IsTrue();
+ }
+
+ [Test]
+ public async Task GameServerImpl_userdisconnected_removes_user_from_waiting_list()
+ {
+ var impl = new GameServerImpl();
+ var user = new GameUser(new UserToken(null!));
+
+ var waiting = GetWaitingList(impl);
+ waiting.Add(user);
+
+ impl.UserDisconnected(user);
+
+ _ = await Assert.That(waiting.Contains(user)).IsFalse();
+ }
+
+ [Test]
+ public async Task UserLobbyState_ignores_non_matching_protocols()
+ {
+ var user = new GameUser(new UserToken(null!));
+ var lobby = new UserLobbyState(user);
+
+ var packet = Packet.Create((short)PROTOCOL.GAME_START);
+ packet.RecordSize();
+ var received = new Packet(new ArraySegment(packet.Buffer, 0, packet.Position), null!);
+
+ var threw = false;
+ try
+ {
+ lobby.OnMessage(received);
+ }
+ catch
+ {
+ threw = true;
+ }
+
+ _ = await Assert.That(threw).IsFalse();
+ }
+
+ private static GameUser GetLatestUser()
+ {
+ var userListField = typeof(Program).GetField("Userlist", BindingFlags.NonPublic | BindingFlags.Static);
+ return userListField?.GetValue(null) is not List userList || userList.Count == 0
+ ? throw new InvalidOperationException("Unable to read Program user list.")
+ : userList[^1];
+ }
+
+ private static List GetWaitingList(GameServerImpl impl)
+ {
+ var field = typeof(GameServerImpl).GetField("_matchingWaitingUsers", BindingFlags.NonPublic | BindingFlags.Instance);
+ return field?.GetValue(impl) is not List waiting
+ ? throw new InvalidOperationException("Unable to read waiting list.")
+ : waiting;
+ }
+}
diff --git a/viruswar/server/GameServer/App.config b/viruswar/server/GameServer/App.config
deleted file mode 100644
index 8e15646..0000000
--- a/viruswar/server/GameServer/App.config
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/viruswar/server/GameServer/AssemblyInfo.cs b/viruswar/server/GameServer/AssemblyInfo.cs
new file mode 100644
index 0000000..c4e4e0b
--- /dev/null
+++ b/viruswar/server/GameServer/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("GameServer.Tests")]
diff --git a/viruswar/server/GameServer/CGameRoom.cs b/viruswar/server/GameServer/CGameRoom.cs
deleted file mode 100644
index a238286..0000000
--- a/viruswar/server/GameServer/CGameRoom.cs
+++ /dev/null
@@ -1,320 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using FreeNet;
-
-namespace GameServer
-{
- using RoomState;
-
- ///
- /// 게임방의 공통적인 기능을 담고 있는 클래스.
- /// 게임에 특화된 로직들은 각 상태에서 처리한다.
- ///
- /// * 게임 패킷 처리 순서
- /// - (클라이언트에서 패킷 전송) ---> (유저) ---> (게임방) ---> (게임방 상태 객체)
- ///
- public class CGameRoom
- {
- public enum STATE
- {
- READY,
- PLAY
- }
-
- //----------------------------------------------
- // GameRoom의 공통적인 부분.
- //----------------------------------------------
- // 게임방들을 관리하는 매니저 객체.
- // 플레이어가 모두 나갔을 때 방을 삭제하기 위해 필요하다.
- CGameRoomManager room_manager;
-
- // 현재 플레이어들.
- List players;
-
- // 프로토콜을 받았는지, 모두한테서 받았는지 등을 체크하는 변수.
- // 플레이어간 상태 동기화를 위해 필요하다.
- Dictionary received_protocol;
-
- // 현재 턴을 진행하고 있는 플레이어의 인덱스.
- byte current_turn_player;
-
- // 게임 상태 관리 매니저.
- // 게임 로직 진행은 각 상태 클래스에서 처리한다.
- public CStateManager state_manager { get; private set; }
-
-
- public CGameRoom(CGameRoomManager room_manager)
- {
- this.room_manager = room_manager;
- this.players = new List();
- this.received_protocol = new Dictionary();
- this.current_turn_player = 0;
-
- this.state_manager = new CStateManager();
- this.state_manager.add(STATE.READY, new CGameRoomReadyState(this));
- this.state_manager.add(STATE.PLAY, new CGameRoomPlayState(this));
- this.state_manager.change_state(STATE.READY);
- }
-
-
- public void reset()
- {
- this.current_turn_player = 0;
- }
-
-
- public void broadcast(CPacket msg)
- {
- for (int i = 0; i < this.players.Count; ++i)
- {
- this.players[i].send(msg);
- }
- }
-
-
- ///
- /// 매칭 성공 후 플레이어들을 방에 입장 시킨다.
- ///
- ///
- ///
- public void enter_gameroom(CPlayer player1, CPlayer player2)
- {
- if (player1 == null || player2 == null)
- {
- throw new Exception("Player cannot be null.");
- }
-
- if (this.players.Count >= 2)
- {
- throw new Exception("This room is not empty.");
- }
-
- add_player(player1);
- add_player(player2);
-
- CPacket msg = CPacket.create((short)PROTOCOL.START_LOADING);
- broadcast(msg);
- }
-
-
- void add_player(CPlayer newbie)
- {
- this.players.Add(newbie);
- }
-
-
- public void destroy()
- {
- CPacket msg = CPacket.create((short)PROTOCOL.ROOM_REMOVED);
- broadcast(msg);
-
- for (int i = 0; i < this.players.Count; ++i)
- {
- this.players[i].removed();
- }
- this.players.Clear();
- }
-
-
- public void remove_self()
- {
- this.room_manager.remove_room(this);
- }
-
-
- ///
- /// 플레이어가 해당 프로토콜을 이미 받았는지 체크함.
- ///
- ///
- ///
- ///
- bool is_received(byte player_index, PROTOCOL protocol)
- {
- if (!this.received_protocol.ContainsKey(player_index))
- {
- return false;
- }
-
- return this.received_protocol[player_index] == protocol;
- }
-
-
- ///
- /// 플레이어가 해당 프로토콜을 받았다고 기록해놓음.
- ///
- ///
- ///
- void checked_protocol(byte player_index, PROTOCOL protocol)
- {
- if (this.received_protocol.ContainsKey(player_index))
- {
- return;
- }
-
- this.received_protocol.Add(player_index, protocol);
- }
-
-
- ///
- /// 모든 플레이어가 해당 프로토콜을 받았는지 체크함.
- /// 플레이어들의 클라이언트 상태 동기화가 필요할 때 호출하여 체크한다.
- /// 못받은 플레이어가 한명이라도 있다면 false를 리턴.
- /// 모두한테서 받았다면 상태를 초기화 하고 true를 리턴.
- ///
- ///
- ///
- public bool all_received(PROTOCOL protocol)
- {
- if (this.received_protocol.Count < this.players.Count)
- {
- return false;
- }
-
- foreach (KeyValuePair kvp in this.received_protocol)
- {
- if (kvp.Value != protocol)
- {
- return false;
- }
- }
-
- clear_received_protocol();
- return true;
- }
-
-
- public void clear_received_protocol()
- {
- this.received_protocol.Clear();
- }
-
-
- ///
- /// 플레이어의 접속이 끊겼을 때.
- ///
- ///
- public void on_player_removed(CPlayer player)
- {
- this.players.Remove(player);
- if (this.players.Count <= 1)
- {
- this.room_manager.remove_room(this);
- }
- }
-
-
- ///
- /// 현재 턴을 진행중인 플레이어를 리턴한다.
- ///
- ///
- public CPlayer get_current_player()
- {
- return this.players[this.current_turn_player];
- }
-
-
- public void turn_next()
- {
- if (get_current_player().player_index < get_player_count() - 1)
- {
- ++this.current_turn_player;
- }
- else
- {
- // 다시 첫번째 플레이어의 턴으로 만들어 준다.
- this.current_turn_player = get_player(0).player_index;
- }
- }
-
-
- public CPlayer get_player(byte player_index)
- {
- return this.players[player_index];
- }
-
-
- public List get_players()
- {
- return this.players;
- }
-
-
- public int get_player_count()
- {
- return this.players.Count;
- }
-
-
- public void each_player(Action function)
- {
- for (int i = 0; i < this.players.Count; ++i)
- {
- function(this.players[i]);
- }
- }
-
-
- ///
- /// 상대방 플레이어를 리턴한다.
- ///
- ///
- public CPlayer get_opponent_player(CPlayer who)
- {
- if (who.player_index == 0)
- {
- return this.players[1];
- }
-
- return this.players[0];
- }
-
-
- ///
- /// 현재 턴 플레이어의 상대방 플레이어를 리턴한다.
- ///
- ///
- public CPlayer get_opponent_player()
- {
- return get_opponent_player(get_current_player());
- }
-
-
- ///
- /// sender가 현재 턴을 진행중인 플레이어가 맞는지 확인한다.
- ///
- ///
- ///
- public bool is_current_player(CPlayer sender)
- {
- return this.current_turn_player == sender.player_index;
- }
-
-
- //--------------------------------------------------------
- // Handler.
- //--------------------------------------------------------
- public void on_receive(CPlayer owner, CPacket msg)
- {
- PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id();
- if (is_received(owner.player_index, protocol))
- {
- // 플레이어가 이미 해당 프로토콜을 전송했다. 중복 처리 하지 않고 리턴한다.
- return;
- }
-
- // 프로토콜을 받았다고 기록한다.
- checked_protocol(owner.player_index, protocol);
-
- // 상태 매니저에 패킷을 보낸 플레이어와 패킷 내용을 전달한다.
- // 이후 게임 로직은 상태 매니저를 통해 현재 수행중인 상태 객체에서 처리된다.
- this.state_manager.send_state_message(protocol, owner, msg);
- }
-
-
- public void error(CPlayer player)
- {
- player.disconnect();
- }
- }
-}
diff --git a/viruswar/server/GameServer/CGameRoomManager.cs b/viruswar/server/GameServer/CGameRoomManager.cs
deleted file mode 100644
index 60c1daf..0000000
--- a/viruswar/server/GameServer/CGameRoomManager.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace GameServer
-{
- ///
- /// 게임방들을 관리하는 룸매니저.
- ///
- public class CGameRoomManager
- {
- List rooms;
-
- public CGameRoomManager()
- {
- this.rooms = new List();
- }
-
-
- ///
- /// 매칭을 요청한 유저들을 넘겨 받아 게임 방을 생성한다.
- ///
- ///
- ///
- public void create_room(CGameUser user1, CGameUser user2)
- {
- // 게임 방을 생성하여 입장 시킴.
- CGameRoom battleroom = new CGameRoom(this);
- this.rooms.Add(battleroom);
-
- user1.enter_room(battleroom, 0);
- user2.enter_room(battleroom, 1);
-
- battleroom.enter_gameroom(user1.player, user2.player);
- }
-
- public void remove_room(CGameRoom room)
- {
- room.destroy();
- this.rooms.Remove(room);
- }
- }
-}
diff --git a/viruswar/server/GameServer/CGameServer.cs b/viruswar/server/GameServer/CGameServer.cs
deleted file mode 100644
index a394111..0000000
--- a/viruswar/server/GameServer/CGameServer.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace GameServer
-{
- using FreeNet;
- using System.Threading;
-
- class CGameServer
- {
- // 게임방을 관리하는 매니저.
- public CGameRoomManager room_manager { get; private set; }
-
- // 매칭 대기 리스트.
- List matching_waiting_users;
- //----------------------------------------------------------------
-
- public CGameServer()
- {
- this.room_manager = new CGameRoomManager();
- this.matching_waiting_users = new List();
- }
-
-
- ///
- /// 유저로부터 매칭 요청이 왔을 때 호출됨.
- ///
- /// 매칭을 신청한 유저 객체
- public void matching_req(CGameUser user)
- {
- // 대기 리스트에 중복 추가 되지 않도록 체크.
- if (this.matching_waiting_users.Contains(user))
- {
- return;
- }
-
- // 매칭 대기 리스트에 추가.
- this.matching_waiting_users.Add(user);
-
- // 2명이 모이면 매칭 성공.
- if (this.matching_waiting_users.Count == 2)
- {
- // 게임 방 생성.
- this.room_manager.create_room(this.matching_waiting_users[0], this.matching_waiting_users[1]);
-
- // 매칭 대기 리스트 삭제.
- this.matching_waiting_users.Clear();
- }
- else
- {
- // 매칭 인원이 모자를 경우 대기 메시지 전송.
- CPacket msg = CPacket.create((short)PROTOCOL.ENTER_GAME_ROOM_ACK);
- user.send(msg);
- }
- }
-
-
- ///
- /// 유저가 끊겼을 경우 매칭 대기 리스트에서 제거.
- ///
- ///
- public void user_disconnected(CGameUser user)
- {
- if (this.matching_waiting_users.Contains(user))
- {
- this.matching_waiting_users.Remove(user);
- }
- }
- }
-}
diff --git a/viruswar/server/GameServer/CGameUser.cs b/viruswar/server/GameServer/CGameUser.cs
deleted file mode 100644
index 320c54e..0000000
--- a/viruswar/server/GameServer/CGameUser.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using FreeNet;
-
-namespace GameServer
-{
- using UserState;
-
- ///
- /// 하나의 session객체를 나타낸다.
- ///
- public class CGameUser : IPeer
- {
- CUserToken token;
-
- public CGameRoom battle_room { get; private set; }
-
- public CPlayer player { get; private set; }
- IUserState current_user_state;
- Dictionary user_states;
-
- public CGameUser(CUserToken token)
- {
- this.token = token;
- this.token.set_peer(this);
-
- this.user_states = new Dictionary();
- this.user_states.Add(USER_STATE_TYPE.LOBBY, new CUserLobbyState(this));
- this.user_states.Add(USER_STATE_TYPE.PLAY, new CUserPlayState(this));
- change_state(USER_STATE_TYPE.LOBBY);
- }
-
- public void change_state(USER_STATE_TYPE state)
- {
- this.current_user_state = this.user_states[state];
- }
-
- void IPeer.on_message(CPacket msg)
- {
- switch ((PROTOCOL)msg.protocol_id)
- {
- case PROTOCOL.CONCURRENT_USERS:
- {
- int count = Program.get_concurrent_user_count();
- CPacket reply = CPacket.create((short)PROTOCOL.CONCURRENT_USERS);
- reply.push(count);
- send(reply);
- }
- return;
- }
-
- this.current_user_state.on_message(msg);
- }
-
- void IPeer.on_removed()
- {
- Console.WriteLine("The client disconnected.");
- Program.remove_user(this);
-
- if (this.battle_room != null)
- {
- this.battle_room.on_player_removed(this.player);
- }
- }
-
- public void send(CPacket msg)
- {
- msg.record_size();
-
- // 소켓 버퍼로 보내기 전에 복사해 놓음.
- byte[] clone = new byte[msg.position];
- Array.Copy(msg.buffer, clone, msg.position);
-
- this.token.send(new ArraySegment(clone, 0, msg.position));
- }
-
- void IPeer.disconnect()
- {
- this.token.ban();
- }
-
- public void enter_room(CGameRoom room, byte player_index)
- {
- this.player = new CPlayer(this, player_index);
- this.battle_room = room;
- change_state(USER_STATE_TYPE.PLAY);
- }
- }
-}
diff --git a/viruswar/server/GameServer/CHelper.cs b/viruswar/server/GameServer/CHelper.cs
deleted file mode 100644
index 32c9832..0000000
--- a/viruswar/server/GameServer/CHelper.cs
+++ /dev/null
@@ -1,168 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace GameServer
-{
- public static class CHelper
- {
- public static void Shuffle(List list)
- {
- Random rng = new Random();
- int n = list.Count;
- while (n > 1)
- {
- n--;
- int k = rng.Next(n + 1);
- T value = list[k];
- list[k] = list[n];
- list[n] = value;
- }
- }
-
-
- static byte COLUMN_COUNT = 7;
-
- ///
- /// 포지션을 (row,col)형식의 좌표로 변환한다.
- ///
- ///
- ///
- static Vector2 convert_to_xy(short position)
- {
- return new Vector2(calc_col(position), calc_row(position));
- }
-
-
- ///
- /// (row, col)형식의 좌표를 포지션으로 변환한다.
- ///
- ///
- ///
- ///
- public static short get_position(byte row, byte col)
- {
- return (short)(row * COLUMN_COUNT + col);
- }
-
-
- ///
- /// 포지션으로부터 세로 인덱스를 구한다.
- ///
- ///
- ///
- public static short calc_row(short position)
- {
- return (short)(position / COLUMN_COUNT);
- }
-
-
- ///
- /// 포지션으로부터 가로 인덱스를 구한다.
- ///
- ///
- ///
- public static short calc_col(short position)
- {
- return (short)(position % COLUMN_COUNT);
- }
-
-
- ///
- /// cell 인덱스를 넣으면 둘 사이의 거리값을 리턴해 준다.
- /// 한칸이 차이나면 1, 두칸이 차이나면 2
- ///
- ///
- ///
- ///
- public static short get_distance(short from, short to)
- {
- Vector2 pos1 = convert_to_xy(from);
- Vector2 pos2 = convert_to_xy(to);
- return get_distance(pos1, pos2);
- }
-
- public static short get_distance(Vector2 pos1, Vector2 pos2)
- {
- Vector2 distance = pos1 - pos2;
-
- short x = (short)Math.Abs(distance.x);
- short y = (short)Math.Abs(distance.y);
-
- // x,y중 큰 값이 실제 두 위치 사이의 거리를 뜻한다.
- return Math.Max(x, y);
- }
-
- public static byte howfar_from_clicked_cell(short basis_cell, short cell)
- {
- short row = (short)(basis_cell / COLUMN_COUNT);
- short col = (short)(basis_cell % COLUMN_COUNT);
- Vector2 basic_pos = new Vector2(col, row);
-
- row = (short)(cell / COLUMN_COUNT);
- col = (short)(cell % COLUMN_COUNT);
- Vector2 cell_pos = new Vector2(col, row);
-
- Vector2 distance = (basic_pos - cell_pos);
- short x = (short)Math.Abs(distance.x);
- short y = (short)Math.Abs(distance.y);
- return (byte)Math.Max(x, y);
- }
-
-
- ///
- /// 주위에 있는 셀의 위치를 찾아서 리스트로 리턴해 준다.
- ///
- ///
- ///
- ///
- ///
- public static List find_neighbor_cells(short basis_cell, List targets, short gap)
- {
- Vector2 pos = convert_to_xy(basis_cell);
- return targets.FindAll(obj => get_distance(pos, convert_to_xy(obj)) <= gap);
- }
-
-
- ///
- /// 게임을 지속 할 수 있는지 체크한다.
- ///
- ///
- ///
- ///
- ///
- public static bool can_play_more(List board, CPlayer current_player, List all_player)
- {
- foreach (short cell in current_player.viruses)
- {
- if (CHelper.find_available_cells(cell, board, all_player).Count > 0)
- {
- return true;
- }
- }
- return false;
- }
-
-
- ///
- /// 이동 가능한 셀을 찾아서 리스트로 돌려준다.
- ///
- ///
- ///
- ///
- ///
- public static List find_available_cells(short basis_cell, List total_cells, List players)
- {
- List targets = find_neighbor_cells(basis_cell, total_cells, 2);
-
- players.ForEach(obj =>
- {
- targets.RemoveAll(number => obj.viruses.Exists(cell => cell == number));
- });
-
- return targets;
- }
- }
-}
diff --git a/viruswar/server/GameServer/CPlayer.cs b/viruswar/server/GameServer/CPlayer.cs
deleted file mode 100644
index d00ec6e..0000000
--- a/viruswar/server/GameServer/CPlayer.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using FreeNet;
-
-public enum PLAYER_TYPE : byte
-{
- HUMAN,
- AI
-}
-
-namespace GameServer
-{
- public class CPlayer
- {
- public delegate void SendFn(CPacket msg);
-
- IPeer owner;
-
- public byte player_index { get; private set; }
- public List viruses { get; private set; }
-
- public CPlayer(CGameUser user, byte player_index)
- {
- this.owner = user;
- this.player_index = player_index;
- this.viruses = new List();
- }
-
- public void reset()
- {
- this.viruses.Clear();
- }
-
- public void add_cell(short position)
- {
- this.viruses.Add(position);
- }
-
- public void remove_cell(short position)
- {
- this.viruses.Remove(position);
- }
-
- public void send(CPacket msg)
- {
- this.owner.send(msg);
- }
-
- public int get_virus_count()
- {
- return this.viruses.Count;
- }
-
- public void removed()
- {
- ((CGameUser)this.owner).change_state(UserState.USER_STATE_TYPE.LOBBY);
- }
-
- public void disconnect()
- {
- this.owner.disconnect();
- }
- }
-}
diff --git a/viruswar/server/GameServer/CPlayerMovingData.cs b/viruswar/server/GameServer/CPlayerMovingData.cs
deleted file mode 100644
index 6d85ff3..0000000
--- a/viruswar/server/GameServer/CPlayerMovingData.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace GameServer
-{
- public enum MOVE_DIRECTION : byte
- {
- NONE,
- UP,
- DOWN,
- LEFT,
- RIGHT,
- UP_LEFT,
- UP_RIGHT,
- DOWN_LEFT,
- DOWN_RIGHT
- }
-
- class CPlayerMovingData
- {
- public CPlayerMovingData(byte player_index, float x, float y, float z)
- {
- this.player_index = player_index;
- this.position_x = x;
- this.position_y = y;
- this.position_z = z;
-
- foreach (MOVE_DIRECTION e in Enum.GetValues(typeof(MOVE_DIRECTION)))
- {
- this.accelerations.Add(e, 0.0f);
- }
- }
-
-
- public byte is_changed;
-
- public byte player_index;
-
- public float position_x;
- public float position_y;
- public float position_z;
-
- public Dictionary accelerations = new Dictionary();
-
- public byte direction;
- }
-}
diff --git a/viruswar/server/GameServer/GameRoom.cs b/viruswar/server/GameServer/GameRoom.cs
new file mode 100644
index 0000000..4b2a47d
--- /dev/null
+++ b/viruswar/server/GameServer/GameRoom.cs
@@ -0,0 +1,268 @@
+using FreeNet;
+using GameServer.RoomState;
+using GameServer.State;
+
+namespace GameServer;
+
+///
+/// Class containing common game-room functionality.
+/// Game-specific logic is handled in each state object.
+/// Packet flow: (client sends packet) -> (user) -> (game room) -> (game room state object)
+///
+public class GameRoom
+{
+ ///
+ /// Current players.
+ ///
+ private readonly List _players;
+
+ ///
+ /// Tracks whether a protocol was received, and whether all players have received it.
+ /// Needed for state synchronization across players.
+ ///
+ private readonly Dictionary _receivedProtocol;
+
+ ///
+ /// ---------------------------------------------- Common GameRoom infrastructure. ----------------------------------------------
+ /// Manager that controls game rooms; used to remove the room when all players leave.
+ ///
+ private readonly GameRoomManager _roomManager;
+
+ ///
+ /// Index of the player currently taking a turn.
+ ///
+ private byte _currentTurnPlayer;
+
+ public GameRoom(GameRoomManager room_manager)
+ {
+ _roomManager = room_manager;
+ _players = [];
+ _receivedProtocol = [];
+ _currentTurnPlayer = 0;
+
+ StateManager = new StateManager();
+ StateManager.Add(STATE.READY, new GameRoomReadyState(this));
+ StateManager.Add(STATE.PLAY, new GameRoomPlayState(this));
+ StateManager.ChangeState(STATE.READY);
+ }
+
+ public enum STATE
+ {
+ READY,
+ PLAY
+ }
+
+ ///
+ /// Game state manager. Gameplay logic is handled by each state class.
+ ///
+ public StateManager StateManager { get; private set; }
+
+ ///
+ /// Errors the specified player.
+ ///
+ /// The player.
+ public static void Error(Player player) => player.Disconnect();
+
+ ///
+ /// Checks whether all players have received the given protocol.
+ /// Call this when client-state synchronization is required.
+ /// Returns false if even one player has not received it; otherwise clears state and returns true.
+ ///
+ ///
+ ///
+ public bool AllReceived(PROTOCOL protocol)
+ {
+ if (_receivedProtocol.Count < _players.Count)
+ {
+ return false;
+ }
+
+ foreach (var kvp in _receivedProtocol)
+ {
+ if (kvp.Value != protocol)
+ {
+ return false;
+ }
+ }
+
+ ClearReceivedProtocol();
+ return true;
+ }
+
+ public void Broadcast(Packet msg)
+ {
+ for (var i = 0; i < _players.Count; ++i)
+ {
+ _players[i].Send(msg);
+ }
+ }
+
+ public void ClearReceivedProtocol() => _receivedProtocol.Clear();
+
+ public void Destroy()
+ {
+ var msg = Packet.Create((short)PROTOCOL.ROOM_REMOVED);
+ Broadcast(msg);
+
+ for (var i = 0; i < _players.Count; ++i)
+ {
+ _players[i].Removed();
+ }
+
+ _players.Clear();
+ }
+
+ ///
+ /// Executes the specified function for each player.
+ ///
+ /// The function to execute.
+ public void EachPlayer(Action function)
+ {
+ for (var i = 0; i < _players.Count; ++i)
+ {
+ function(_players[i]);
+ }
+ }
+
+ ///
+ /// Places players into the room after matching succeeds.
+ ///
+ ///
+ ///
+ public void EnterGameRoom(Player? player1, Player? player2)
+ {
+ if (player1 is null || player2 is null)
+ {
+ throw new Exception("Player cannot be null.");
+ }
+
+ if (_players.Count >= 2)
+ {
+ throw new Exception("This room is not empty.");
+ }
+
+ AddPlayer(player1);
+ AddPlayer(player2);
+
+ var msg = Packet.Create((short)PROTOCOL.START_LOADING);
+ Broadcast(msg);
+ }
+
+ ///
+ /// Returns the player currently taking a turn.
+ ///
+ ///
+ public Player GetCurrentPlayer() => _players[_currentTurnPlayer];
+
+ ///
+ /// Returns the opponent player.
+ ///
+ ///
+ public Player GetOpponentPlayer(Player who) => who.PlayerIndex == 0 ? _players[1] : _players[0];
+
+ ///
+ /// Returns the opponent of the current-turn player.
+ ///
+ ///
+ public Player GetOpponentPlayer() => GetOpponentPlayer(GetCurrentPlayer());
+
+ public Player GetPlayer(byte player_index) => _players[player_index];
+
+ public int GetPlayerCount() => _players.Count;
+
+ public List GetPlayers() => _players;
+
+ ///
+ /// Checks whether sender is the player currently taking a turn.
+ ///
+ ///
+ ///
+ public bool IsCurrentPlayer(Player sender) => _currentTurnPlayer == sender.PlayerIndex;
+
+ ///
+ /// Called when a player's connection is closed.
+ ///
+ ///
+ public void OnPlayerRemoved(Player? player)
+ {
+ if (player is null)
+ {
+ return;
+ }
+
+ _ = _players.Remove(player);
+ if (_players.Count <= 1)
+ {
+ _roomManager.RemoveRoom(this);
+ }
+ }
+
+ ///
+ /// Called when a player sends a packet to the room.
+ ///
+ /// The owner.
+ /// The message.
+ public void OnReceive(Player? owner, Packet message)
+ {
+ if (owner is null)
+ {
+ return;
+ }
+
+ var protocol = (PROTOCOL)message.PopProtocolId();
+ if (IsReceived(owner.PlayerIndex, protocol))
+ {
+ // Player already sent this protocol. Return without duplicate handling.
+ return;
+ }
+
+ // Record that this protocol was received.
+ CheckedProtocol(owner.PlayerIndex, protocol);
+
+ // Forward sender and packet data to the state manager.
+ // Subsequent game logic is handled by the currently active state object.
+ StateManager.SendStateMessage(protocol, owner, message);
+ }
+
+ public void RemoveSelf() => _roomManager.RemoveRoom(this);
+
+ public void Reset() => _currentTurnPlayer = 0;
+
+ public void TurnNext()
+ {
+ if (GetCurrentPlayer().PlayerIndex < GetPlayerCount() - 1)
+ {
+ ++_currentTurnPlayer;
+ }
+ else
+ {
+ // Wrap back to the first player's turn.
+ _currentTurnPlayer = GetPlayer(0).PlayerIndex;
+ }
+ }
+
+ private void AddPlayer(Player newbie) => _players.Add(newbie);
+
+ ///
+ /// Records that a player received the specified protocol.
+ ///
+ ///
+ ///
+ private void CheckedProtocol(byte player_index, PROTOCOL protocol)
+ {
+ if (_receivedProtocol.ContainsKey(player_index))
+ {
+ return;
+ }
+
+ _receivedProtocol.Add(player_index, protocol);
+ }
+
+ ///
+ /// Checks whether a player already received the specified protocol.
+ ///
+ ///
+ ///
+ ///
+ private bool IsReceived(byte player_index, PROTOCOL protocol) => _receivedProtocol.TryGetValue(player_index, out var value) && value == protocol;
+}
diff --git a/viruswar/server/GameServer/GameRoomManager.cs b/viruswar/server/GameServer/GameRoomManager.cs
new file mode 100644
index 0000000..08ea1a4
--- /dev/null
+++ b/viruswar/server/GameServer/GameRoomManager.cs
@@ -0,0 +1,36 @@
+namespace GameServer;
+
+///
+/// Room manager that manages game rooms.
+///
+public class GameRoomManager
+{
+ private readonly List _rooms = [];
+
+ ///
+ /// Creates a game room for users who requested matching.
+ ///
+ /// First user who requested matching.
+ /// Second user who requested matching.
+ public void CreateRoom(GameUser user1, GameUser user2)
+ {
+ // Create the game room and let players enter.
+ var battleroom = new GameRoom(this);
+ _rooms.Add(battleroom);
+
+ user1.EnterRoom(battleroom, 0);
+ user2.EnterRoom(battleroom, 1);
+
+ battleroom.EnterGameRoom(user1.Player, user2.Player);
+ }
+
+ ///
+ /// Removes a game room.
+ ///
+ /// Game room to remove.
+ public void RemoveRoom(GameRoom room)
+ {
+ room.Destroy();
+ _ = _rooms.Remove(room);
+ }
+}
diff --git a/viruswar/server/GameServer/GameServer.csproj b/viruswar/server/GameServer/GameServer.csproj
index 5315328..6095b64 100644
--- a/viruswar/server/GameServer/GameServer.csproj
+++ b/viruswar/server/GameServer/GameServer.csproj
@@ -1,83 +1,13 @@
-
-
-
+
+
- Debug
- AnyCPU
- {341BA7A5-4942-48F4-885C-D186EA81BC92}
Exe
- Properties
- GameServer
- GameServer
- v4.5
- 512
+ net10.0
+ enable
-
- AnyCPU
- true
- full
- false
- bin\Debug\
- DEBUG;TRACE
- prompt
- 4
-
-
- AnyCPU
- pdbonly
- true
- bin\Release\
- TRACE
- prompt
- 4
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
- {90786d2b-f7a9-4a90-a073-838ef232ba6a}
- FreeNet
-
+
-
-
-
-
\ No newline at end of file
+
+
diff --git a/viruswar/server/GameServer/GameServerImpl.cs b/viruswar/server/GameServer/GameServerImpl.cs
new file mode 100644
index 0000000..e54c375
--- /dev/null
+++ b/viruswar/server/GameServer/GameServerImpl.cs
@@ -0,0 +1,64 @@
+using FreeNet;
+
+namespace GameServer;
+
+///
+/// Concrete implementation of the game server.
+///
+internal class GameServerImpl
+{
+ ///
+ /// Matchmaking waiting list.
+ ///
+ private readonly List _matchingWaitingUsers = [];
+
+ ///
+ /// Manager that controls game rooms.
+ ///
+ /// The room manager.
+ public GameRoomManager RoomManager { get; private set; } = new();
+
+ ///
+ /// Called when a user requests matchmaking.
+ ///
+ /// User object that requested matching.
+ public void MatchingReq(GameUser user)
+ {
+ // Prevent duplicate insertion into the waiting list.
+ if (_matchingWaitingUsers.Contains(user))
+ {
+ return;
+ }
+
+ // Add to matchmaking waiting list.
+ _matchingWaitingUsers.Add(user);
+
+ // Match succeeds when two users are waiting.
+ if (_matchingWaitingUsers.Count == 2)
+ {
+ // Create a game room.
+ RoomManager.CreateRoom(_matchingWaitingUsers[0], _matchingWaitingUsers[1]);
+
+ // Clear matchmaking waiting list.
+ _matchingWaitingUsers.Clear();
+ }
+ else
+ {
+ // Send a waiting message if not enough users are matched yet.
+ var msg = Packet.Create((short)PROTOCOL.ENTER_GAME_ROOM_ACK);
+ user.Send(msg);
+ }
+ }
+
+ ///
+ /// Removes a disconnected user from the matchmaking waiting list.
+ ///
+ /// Disconnected user object.
+ public void UserDisconnected(GameUser user)
+ {
+ if (_matchingWaitingUsers.Contains(user))
+ {
+ _ = _matchingWaitingUsers.Remove(user);
+ }
+ }
+}
diff --git a/viruswar/server/GameServer/GameUser.cs b/viruswar/server/GameServer/GameUser.cs
new file mode 100644
index 0000000..3be0b95
--- /dev/null
+++ b/viruswar/server/GameServer/GameUser.cs
@@ -0,0 +1,82 @@
+using FreeNet;
+using GameServer.UserState;
+
+namespace GameServer;
+
+///
+/// Represents a single session object.
+///
+public class GameUser : IPeer
+{
+ private readonly UserToken _token;
+ private readonly Dictionary _userStates;
+ private IUserState? _currentUserState;
+
+ public GameUser(UserToken token)
+ {
+ _token = token;
+ _token.Peer = this;
+
+ _userStates = new Dictionary
+ {
+ { UserStateType.Lobby, new UserLobbyState(this) },
+ { UserStateType.Play, new UserPlayState(this) }
+ };
+ ChangeState(UserStateType.Lobby);
+ }
+
+ public GameRoom? BattleRoom { get; private set; }
+
+ public Player? Player { get; private set; }
+
+ public void ChangeState(UserStateType state) => _currentUserState = _userStates[state];
+
+ public void Disconnect() => _token.Ban();
+
+ public void EnterRoom(GameRoom room, byte player_index)
+ {
+ Player = new Player(this, player_index);
+ BattleRoom = room;
+ ChangeState(UserStateType.Play);
+ }
+
+ ///
+ public void OnMessage(Packet msg)
+ {
+ switch ((PROTOCOL)msg.ProtocolId)
+ {
+ case PROTOCOL.CONCURRENT_USERS:
+ {
+ var count = Program.GetConcurrentUserCount();
+ var reply = Packet.Create((short)PROTOCOL.CONCURRENT_USERS);
+ reply.Push(count);
+ Send(reply);
+ }
+
+ return;
+ }
+
+ _currentUserState?.OnMessage(msg);
+ }
+
+ ///
+ public void OnRemoved()
+ {
+ Console.WriteLine("The client disconnected.");
+ Program.RemoveUser(this);
+
+ BattleRoom?.OnPlayerRemoved(Player);
+ }
+
+ ///
+ public void Send(Packet msg)
+ {
+ msg.RecordSize();
+
+ // Copy before sending to the socket buffer.
+ var clone = new byte[msg.Position];
+ Array.Copy(msg.Buffer, clone, msg.Position);
+
+ _token.Send(new ArraySegment(clone, 0, msg.Position));
+ }
+}
diff --git a/viruswar/server/GameServer/Helper.cs b/viruswar/server/GameServer/Helper.cs
new file mode 100644
index 0000000..305cab1
--- /dev/null
+++ b/viruswar/server/GameServer/Helper.cs
@@ -0,0 +1,152 @@
+using System.Numerics;
+
+namespace GameServer;
+
+public static class Helper
+{
+ private static readonly byte ColumnCount = 7;
+
+ ///
+ /// Gets the column index from a position.
+ ///
+ ///
+ ///
+ public static short CalcColumn(short position) => (short)(position % ColumnCount);
+
+ ///
+ /// Gets the row index from a position.
+ ///
+ ///
+ ///
+ public static short CalcRow(short position) => (short)(position / ColumnCount);
+
+ ///
+ /// Checks whether the game can continue.
+ ///
+ /// The list of all cells on the board.
+ /// The current player.
+ /// The list of all players.
+ /// True if the game can continue, otherwise false.
+ public static bool CanPlayMore(List board, Player currentPlayer, List allPlayer)
+ {
+ foreach (var cell in currentPlayer.Viruses)
+ {
+ if (Helper.FindAvailableCells(cell, board, allPlayer).Count > 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Finds movable cells and returns them as a list.
+ ///
+ /// The basis cell to find available cells for.
+ /// The list of all cells on the board.
+ /// The list of all players.
+ /// A list of available cells that the basis cell can move to.
+ public static List FindAvailableCells(short basisCell, List totalCells, List players)
+ {
+ var targets = FindNeighborCells(basisCell, totalCells, 2);
+
+ foreach (var player in players)
+ {
+ _ = targets.RemoveAll(number => player.Viruses.Exists(cell => cell == number));
+ }
+
+ return targets;
+ }
+
+ ///
+ /// Finds nearby cell positions and returns them as a list.
+ ///
+ /// The basis cell to find neighbors for.
+ /// The list of target cells to consider.
+ /// The maximum distance to consider a cell a neighbor.
+ /// A list of neighboring cells within the specified gap.
+ public static List FindNeighborCells(short basisCell, List targets, short gap)
+ {
+ var pos = ConvertToXY(basisCell);
+ return targets.FindAll(obj => GetDistance(pos, ConvertToXY(obj)) <= gap);
+ }
+
+ ///
+ /// Returns distance between two cell indices. One-cell gap = 1, two-cell gap = 2.
+ ///
+ /// The starting position.
+ /// The ending position.
+ /// The distance between the two positions.
+ public static short GetDistance(short from, short to)
+ {
+ var pos1 = ConvertToXY(from);
+ var pos2 = ConvertToXY(to);
+ return GetDistance(pos1, pos2);
+ }
+
+ ///
+ /// Gets the distance.
+ ///
+ /// The first position.
+ /// The second position.
+ /// System.Int16.
+ public static short GetDistance(Vector2 first, Vector2 second)
+ {
+ var distance = first - second;
+
+ var x = (short)Math.Abs(distance.X);
+ var y = (short)Math.Abs(distance.Y);
+
+ // The larger of x and y represents the actual distance between the two positions.
+ return Math.Max(x, y);
+ }
+
+ ///
+ /// Converts (row, col) coordinates to a position.
+ ///
+ /// The row index.
+ /// The column index.
+ /// The position corresponding to the specified row and column.
+ public static short GetPosition(byte row, byte column) => (short)((row * ColumnCount) + column);
+
+ public static byte HowFarFromClickedCell(short basisCell, short cell)
+ {
+ var row = (short)(basisCell / ColumnCount);
+ var col = (short)(basisCell % ColumnCount);
+ var basicPos = new Vector2(col, row);
+
+ row = (short)(cell / ColumnCount);
+ col = (short)(cell % ColumnCount);
+ var cellPos = new Vector2(col, row);
+
+ var distance = basicPos - cellPos;
+ var x = (short)Math.Abs(distance.X);
+ var y = (short)Math.Abs(distance.Y);
+ return (byte)Math.Max(x, y);
+ }
+
+ ///
+ /// Shuffles the specified list.
+ ///
+ /// The type of elements in the list.
+ /// The list.
+ public static void Shuffle(List list)
+ {
+ var rng = new Random();
+ var n = list.Count;
+ while (n > 1)
+ {
+ n--;
+ var k = rng.Next(n + 1);
+ (list[n], list[k]) = (list[k], list[n]);
+ }
+ }
+
+ ///
+ /// Converts a position to (row, col) coordinates.
+ ///
+ /// The position to convert.
+ /// The corresponding (row, col) coordinates.
+ private static Vector2 ConvertToXY(short position) => new(CalcColumn(position), CalcRow(position));
+}
diff --git a/viruswar/server/GameServer/Player.cs b/viruswar/server/GameServer/Player.cs
new file mode 100644
index 0000000..5af5c98
--- /dev/null
+++ b/viruswar/server/GameServer/Player.cs
@@ -0,0 +1,27 @@
+using FreeNet;
+
+namespace GameServer;
+
+public class Player(GameUser user, byte playerIndex)
+{
+ private readonly GameUser _owner = user;
+
+ public delegate void SendFn(Packet message);
+
+ public byte PlayerIndex { get; private set; } = playerIndex;
+ public List Viruses { get; private set; } = [];
+
+ public void AddCell(short position) => Viruses.Add(position);
+
+ public void Disconnect() => _owner.Disconnect();
+
+ public int GetVirusCount() => Viruses.Count;
+
+ public void RemoveCell(short position) => Viruses.Remove(position);
+
+ public void Removed() => _owner.ChangeState(UserState.UserStateType.Lobby);
+
+ public void Reset() => Viruses.Clear();
+
+ public void Send(Packet message) => _owner.Send(message);
+}
diff --git a/viruswar/server/GameServer/PlayerMovingData.cs b/viruswar/server/GameServer/PlayerMovingData.cs
new file mode 100644
index 0000000..d94a514
--- /dev/null
+++ b/viruswar/server/GameServer/PlayerMovingData.cs
@@ -0,0 +1,40 @@
+namespace GameServer;
+
+public enum MOVE_DIRECTION : byte
+{
+ NONE,
+ UP,
+ DOWN,
+ LEFT,
+ RIGHT,
+ UP_LEFT,
+ UP_RIGHT,
+ DOWN_LEFT,
+ DOWN_RIGHT
+}
+
+internal class PlayerMovingData
+{
+ public Dictionary Accelerations = [];
+
+ public byte PlayerIndex;
+
+ public float PositionX;
+
+ public float PositionY;
+
+ public float PositionZ;
+
+ public PlayerMovingData(byte playerIndex, float x, float y, float z)
+ {
+ PlayerIndex = playerIndex;
+ PositionX = x;
+ PositionY = y;
+ PositionZ = z;
+
+ foreach (var e in Enum.GetValues())
+ {
+ Accelerations.Add(e, 0.0f);
+ }
+ }
+}
diff --git a/viruswar/server/GameServer/PlayerType.cs b/viruswar/server/GameServer/PlayerType.cs
new file mode 100644
index 0000000..12d9520
--- /dev/null
+++ b/viruswar/server/GameServer/PlayerType.cs
@@ -0,0 +1,7 @@
+namespace GameServer;
+
+public enum PlayerType : byte
+{
+ Human,
+ AI
+}
diff --git a/viruswar/server/GameServer/Program.cs b/viruswar/server/GameServer/Program.cs
index f7e8fe4..9b43264 100644
--- a/viruswar/server/GameServer/Program.cs
+++ b/viruswar/server/GameServer/Program.cs
@@ -1,64 +1,42 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using FreeNet;
-
-namespace GameServer
-{
- class Program
- {
- static List userlist;
- public static CGameServer game_main = new CGameServer();
-
- static void Main(string[] args)
- {
- userlist = new List();
-
- CNetworkService service = new CNetworkService(true);
- // 콜백 매소드 설정.
- service.session_created_callback += on_session_created;
- // 초기화.
- service.initialize(10000, 1024);
- service.listen("0.0.0.0", 20000, 100);
-
-
- Console.WriteLine("Started!");
- while (true)
- {
- string input = Console.ReadLine();
- //Console.Write(".");
- System.Threading.Thread.Sleep(1000);
- }
-
- Console.ReadKey();
- }
+using FreeNet;
+using GameServer;
+var service = new NetworkService(true);
+// Set callback methods.
+service.SessionCreated += OnSessionCreated;
+// Initialize.
+service.Listen("0.0.0.0", 20000, 100);
- static void on_session_created(CUserToken token)
- {
- CGameUser user = new CGameUser(token);
- lock (userlist)
- {
- userlist.Add(user);
- }
- }
+Console.WriteLine("Started!");
+while (true)
+{
+ var input = Console.ReadLine();
+ Thread.Sleep(1000);
+}
+internal partial class Program
+{
+ private static readonly List Userlist = [];
+ public static GameServerImpl GameMain { get; } = new();
- public static void remove_user(CGameUser user)
- {
- lock (userlist)
- {
- userlist.Remove(user);
- game_main.user_disconnected(user);
- }
- }
+ public static int GetConcurrentUserCount() => Userlist.Count;
+ public static void OnSessionCreated(object? sender, SessionEventArgs e)
+ {
+ var user = new GameUser(e.Token);
+ lock (Userlist)
+ {
+ Userlist.Add(user);
+ }
+ }
- public static int get_concurrent_user_count()
+ public static void RemoveUser(GameUser user)
+ {
+ lock (Userlist)
{
- return userlist.Count;
+ _ = Userlist.Remove(user);
+
+ GameMain.UserDisconnected(user);
}
- }
+ }
}
diff --git a/viruswar/server/GameServer/Properties/AssemblyInfo.cs b/viruswar/server/GameServer/Properties/AssemblyInfo.cs
deleted file mode 100644
index 56a770e..0000000
--- a/viruswar/server/GameServer/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("GameServer")]
-[assembly: AssemblyDescription("")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("")]
-[assembly: AssemblyProduct("GameServer")]
-[assembly: AssemblyCopyright("Copyright © 2014")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components. If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]
-
-// The following GUID is for the ID of the typelib if this project is exposed to COM
-[assembly: Guid("9a027ede-b3d2-4ef0-8df4-f76172b32414")]
-
-// Version information for an assembly consists of the following four values:
-//
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-//
-// You can specify all the values or you can default the Build and Revision Numbers
-// by using the '*' as shown below:
-// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.0.0")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/viruswar/server/GameServer/RoomState/CGameRoomPlayState.cs b/viruswar/server/GameServer/RoomState/CGameRoomPlayState.cs
deleted file mode 100644
index 7345462..0000000
--- a/viruswar/server/GameServer/RoomState/CGameRoomPlayState.cs
+++ /dev/null
@@ -1,335 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace GameServer.RoomState
-{
- using FreeNet;
-
- class CGameRoomPlayState : IState
- {
- CGameRoom room;
-
- // 게임 보드판.
- List gameboard;
-
- // 0~49까지의 인덱스를 갖고 있는 보드판 데이터.
- List table_board;
-
- static byte COLUMN_COUNT = 7;
-
- readonly short EMPTY_SLOT = short.MaxValue;
-
-
- public CGameRoomPlayState(CGameRoom room)
- {
- this.room = room;
- this.room.state_manager.register_message_handler(this, PROTOCOL.MOVING_REQ, this.moving_req);
- this.room.state_manager.register_message_handler(this, PROTOCOL.TURN_FINISHED_REQ, this.turn_finished);
-
- // 7*7(총 49칸)모양의 보드판을 구성한다.
- // 초기에는 모두 빈공간이므로 EMPTY_SLOT으로 채운다.
- this.gameboard = new List();
- this.table_board = new List();
- for (byte i = 0; i < COLUMN_COUNT * COLUMN_COUNT; ++i)
- {
- this.gameboard.Add(EMPTY_SLOT);
- this.table_board.Add(i);
- }
- }
-
-
- void IState.on_enter()
- {
- battle_start();
- }
-
-
- void IState.on_exit()
- {
- }
-
-
- ///
- /// 게임을 시작한다.
- ///
- void battle_start()
- {
- // 게임을 새로 시작할 때 마다 초기화해줘야 할 것들.
- this.room.reset();
- reset_gamedata();
-
- this.room.each_player(player =>
- {
- // 게임 시작 메시지 전송.
- CPacket msg = CPacket.create((short)PROTOCOL.GAME_START);
-
- // 해당 플레이어 본인의 인덱스.
- msg.push(player.player_index);
-
- // 플레이어들의 세균 위치 전송.
- msg.push((byte)this.room.get_player_count());
- this.room.each_player(_player =>
- {
- msg.push(_player.player_index); // 누구인지 구분하기 위한 플레이어 인덱스.
-
- // 플레이어가 소지한 세균들의 전체 개수.
- byte cell_count = (byte)_player.viruses.Count;
- msg.push(cell_count);
- // 플레이어의 세균들의 위치정보.
- _player.viruses.ForEach(position => msg.push_int16(position));
- });
-
- // 첫 턴을 진행할 플레이어 인덱스.
- msg.push(this.room.get_current_player().player_index);
-
- player.send(msg);
- });
- }
-
-
- ///
- /// 턴을 시작하라고 클라이언트들에게 알려 준다.
- ///
- void start_turn()
- {
- CPacket msg = CPacket.create((short)PROTOCOL.START_PLAYER_TURN);
- msg.push(this.room.get_current_player().player_index);
- this.room.broadcast(msg);
- }
-
-
- ///
- /// 게임 데이터를 초기화 한다.
- /// 게임을 새로 시작할 때 마다 초기화 해줘야 할 것들을 넣는다.
- ///
- void reset_gamedata()
- {
- // 플레이어 데이터 초기화.
- this.room.each_player(player => player.reset());
-
- // 보드판 데이터 초기화.
- for (int i = 0; i < this.gameboard.Count; ++i)
- {
- this.gameboard[i] = EMPTY_SLOT;
- }
- // 1번 플레이어의 세균은 왼쪽위(0,0), 오른쪽위(0,6) 두군데에 배치한다.
- put_virus(0, 0, 0);
- put_virus(0, 0, 6);
- // 2번 플레이어는 세균은 왼쪽아래(6,0), 오른쪽아래(6,6) 두군데에 배치한다.
- put_virus(1, 6, 0);
- put_virus(1, 6, 6);
- }
-
-
- ///
- /// 보드판에 플레이어의 세균을 배치한다.
- ///
- ///
- ///
- ///
- void put_virus(byte player_index, byte row, byte col)
- {
- short position = CHelper.get_position(row, col);
- put_virus(player_index, position);
- }
-
-
- ///
- /// 보드판에 플레이어의 세균을 배치한다.
- ///
- ///
- ///
- void put_virus(byte player_index, short position)
- {
- this.gameboard[position] = player_index;
- this.room.get_player(player_index).add_cell(position);
- }
-
-
- ///
- /// 배치된 세균을 삭제한다.
- ///
- ///
- ///
- void remove_virus(byte player_index, short position)
- {
- this.gameboard[position] = EMPTY_SLOT;
- this.room.get_player(player_index).remove_cell(position);
- }
-
-
- ///
- /// 상대방의 세균을 감염 시킨다.
- ///
- ///
- ///
- ///
- public void infect(short basis_cell, CPlayer attacker, CPlayer victim)
- {
- // 방어자의 세균중에 기준위치로 부터 1칸 반경에 있는 세균들이 감염 대상이다.
- List neighbors = CHelper.find_neighbor_cells(basis_cell, victim.viruses, 1);
- foreach (short position in neighbors)
- {
- // 방어자의 세균을 삭제한다.
- remove_virus(victim.player_index, position);
-
- // 공격자의 세균을 추가하고,
- put_virus(attacker.player_index, position);
- }
- }
-
-
- ///
- /// 클라이언트의 이동 요청.
- ///
- /// 요청한 유저
- /// 시작 위치
- /// 이동하고자 하는 위치
- public void moving_req(CPlayer sender, CPacket received_data)
- {
- this.room.clear_received_protocol();
-
- short begin_pos = received_data.pop_int16();
- short target_pos = received_data.pop_int16();
-
- // sender차례인지 체크.
- if (!this.room.is_current_player(sender))
- {
- this.room.error(sender);
- return;
- }
-
- // begin_pos에 sender의 세균이 존재하는지 체크.
- if (this.gameboard[begin_pos] != sender.player_index)
- {
- // 시작 위치에 해당 플레이어의 세균이 존재하지 않는다.
- this.room.error(sender);
- return;
- }
-
- // 목적지는 EMPTY_SLOT으로 설정된 빈 공간이어야 한다.
- // 다른 세균이 자리하고 있는 곳으로는 이동할 수 없다.
- if (this.gameboard[target_pos] != EMPTY_SLOT)
- {
- // 목적지에 다른 세균이 존재한다.
- this.room.error(sender);
- return;
- }
-
- // target_pos가 이동 또는 복제 가능한 범위인지 체크.
- short distance = CHelper.get_distance(begin_pos, target_pos);
- if (distance > 2)
- {
- // 2칸을 초과하는 거리는 이동할 수 없다.
- this.room.error(sender);
- return;
- }
-
- if (distance <= 0)
- {
- // 자기 자신의 위치로는 이동할 수 없다.
- this.room.error(sender);
- return;
- }
-
- // 모든 체크가 정상이라면 이동을 처리한다.
- if (distance == 1) // 이동 거리가 한칸일 경우에는 복제를 수행한다.
- {
- put_virus(sender.player_index, target_pos);
- }
- else if (distance == 2) // 이동 거리가 두칸일 경우에는 이동을 수행한다.
- {
- // 이전 위치에 있는 세균은 삭제한다.
- remove_virus(sender.player_index, begin_pos);
-
- // 새로운 위치에 세균을 놓는다.
- put_virus(sender.player_index, target_pos);
- }
-
- // 목적지를 기준으로 주위에 존재하는 상대방 세균을 감염시켜 같은 편으로 만든다.
- CPlayer opponent = this.room.get_opponent_player();
- infect(target_pos, sender, opponent);
-
- // 최종 결과를 broadcast한다.
- CPacket msg = CPacket.create((short)PROTOCOL.PLAYER_MOVED);
- msg.push(sender.player_index); // 누가
- msg.push(begin_pos); // 어디서
- msg.push(target_pos); // 어디로 이동 했는지
- this.room.broadcast(msg);
- }
-
-
- ///
- /// 클라이언트에서 턴 연출이 모두 완료 되었을 때 호출된다.
- ///
- ///
- public void turn_finished(CPlayer sender, CPacket msg)
- {
- if (!this.room.all_received(PROTOCOL.TURN_FINISHED_REQ))
- {
- return;
- }
-
- // 턴을 넘긴다.
- turn_end();
- }
-
-
- ///
- /// 턴을 종료한다. 게임이 끝났는지 확인하는 과정을 수행한다.
- ///
- void turn_end()
- {
- // 보드판 상태를 확인하여 게임이 끝났는지 검사한다.
- if (!CHelper.can_play_more(this.table_board, this.room.get_opponent_player(), this.room.get_players()))
- {
- game_over();
- return;
- }
-
- // 아직 게임이 끝나지 않았다면 다음 플레이어로 턴을 넘긴다.
- this.room.turn_next();
-
- // 턴을 시작한다.
- start_turn();
- }
-
-
- void game_over()
- {
- // 우승자 가리기.
- byte win_player_index = byte.MaxValue;
- int count_1p = this.room.get_player(0).get_virus_count();
- int count_2p = this.room.get_player(1).get_virus_count();
-
- if (count_1p == count_2p)
- {
- // 동점인 경우.
- win_player_index = byte.MaxValue;
- }
- else
- {
- if (count_1p > count_2p)
- {
- win_player_index = this.room.get_player(0).player_index;
- }
- else
- {
- win_player_index = this.room.get_player(1).player_index;
- }
- }
-
-
- CPacket msg = CPacket.create((short)PROTOCOL.GAME_OVER);
- msg.push(win_player_index);
- msg.push(count_1p);
- msg.push(count_2p);
- this.room.broadcast(msg);
-
- this.room.remove_self();
- }
- }
-}
diff --git a/viruswar/server/GameServer/RoomState/CGameRoomReadyState.cs b/viruswar/server/GameServer/RoomState/CGameRoomReadyState.cs
deleted file mode 100644
index ca908af..0000000
--- a/viruswar/server/GameServer/RoomState/CGameRoomReadyState.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace GameServer.RoomState
-{
- using FreeNet;
-
- class CGameRoomReadyState : IState
- {
- CGameRoom room;
-
-
- public CGameRoomReadyState(CGameRoom room)
- {
- this.room = room;
-
- this.room.state_manager.register_message_handler(this, PROTOCOL.READY_TO_START, this.on_ready_req);
- }
-
-
- void IState.on_enter()
- {
- }
-
-
- void IState.on_exit()
- {
- }
-
-
- void on_ready_req(CPlayer sender, CPacket msg)
- {
- if (!this.room.all_received(PROTOCOL.READY_TO_START))
- {
- return;
- }
-
- this.room.state_manager.change_state(CGameRoom.STATE.PLAY);
- }
- }
-}
diff --git a/viruswar/server/GameServer/RoomState/GameRoomPlayState.cs b/viruswar/server/GameServer/RoomState/GameRoomPlayState.cs
new file mode 100644
index 0000000..9c5eb25
--- /dev/null
+++ b/viruswar/server/GameServer/RoomState/GameRoomPlayState.cs
@@ -0,0 +1,311 @@
+using FreeNet;
+using GameServer.State;
+
+namespace GameServer.RoomState;
+
+internal class GameRoomPlayState : IState
+{
+ ///
+ /// The number of columns in the game board.
+ ///
+ private static readonly byte ColumnCount = 7;
+
+ private readonly short _emptySlot = short.MaxValue;
+
+ ///
+ /// Game board.
+ ///
+ private readonly List _gameBoard;
+
+ private readonly GameRoom _room;
+
+ ///
+ /// Board index data containing indices 0 through 49.
+ ///
+ private readonly List _tableBoard;
+
+ public GameRoomPlayState(GameRoom room)
+ {
+ _room = room;
+ _room.StateManager.RegisterMessageHandler(this, PROTOCOL.MOVING_REQ, MovingReq);
+ _room.StateManager.RegisterMessageHandler(this, PROTOCOL.TURN_FINISHED_REQ, TurnFinished);
+
+ // Build a 7*7 board (49 cells total). Initialize all cells to EMPTY_SLOT.
+ _gameBoard = [];
+ _tableBoard = [];
+ for (byte i = 0; i < ColumnCount * ColumnCount; ++i)
+ {
+ _gameBoard.Add(_emptySlot);
+ _tableBoard.Add(i);
+ }
+ }
+
+ ///
+ /// Infects opponent viruses.
+ ///
+ /// The basis cell for infection.
+ /// The player who is attacking.
+ /// The player who is being attacked.
+ public void Infect(short basis_cell, Player attacker, Player victim)
+ {
+ // Defender viruses within distance 1 of basis position are infection targets.
+ var neighbors = Helper.FindNeighborCells(basis_cell, victim.Viruses, 1);
+ foreach (var position in neighbors)
+ {
+ // Remove defender virus.
+ RemoveVirus(victim.PlayerIndex, position);
+
+ // Add attacker virus,
+ PutVirus(attacker.PlayerIndex, position);
+ }
+ }
+
+ ///
+ /// Handles a client move request.
+ ///
+ /// Requesting user.
+ /// Start position.
+ /// Target position to move to.
+ public void MovingReq(Player sender, Packet receivedData)
+ {
+ _room.ClearReceivedProtocol();
+
+ var begin_pos = receivedData.PopInt16();
+ var target_pos = receivedData.PopInt16();
+
+ // Check whether it is sender's turn.
+ if (!_room.IsCurrentPlayer(sender))
+ {
+ GameRoom.Error(sender);
+ return;
+ }
+
+ // Check that sender has a virus at begin_pos.
+ if (_gameBoard[begin_pos] != sender.PlayerIndex)
+ {
+ // No sender virus exists at the start position.
+ GameRoom.Error(sender);
+ return;
+ }
+
+ // Target must be an EMPTY_SLOT. Cannot move to a cell occupied by another virus.
+ if (_gameBoard[target_pos] != _emptySlot)
+ {
+ // Another virus occupies the target position.
+ GameRoom.Error(sender);
+ return;
+ }
+
+ // Check whether target_pos is in move/clone range.
+ var distance = Helper.GetDistance(begin_pos, target_pos);
+ if (distance > 2)
+ {
+ // Distances over 2 cells are invalid.
+ GameRoom.Error(sender);
+ return;
+ }
+
+ if (distance <= 0)
+ {
+ // Cannot move to the same position.
+ GameRoom.Error(sender);
+ return;
+ }
+
+ // If all checks pass, process movement.
+ if (distance == 1) // If move distance is 1 cell, perform clone.
+ {
+ PutVirus(sender.PlayerIndex, target_pos);
+ }
+ else if (distance == 2) // If move distance is 2 cells, perform move.
+ {
+ // Remove virus from previous position.
+ RemoveVirus(sender.PlayerIndex, begin_pos);
+
+ // Place virus at new position.
+ PutVirus(sender.PlayerIndex, target_pos);
+ }
+
+ // Infect nearby opponent viruses around target and convert them to sender side.
+ var opponent = _room.GetOpponentPlayer();
+ Infect(target_pos, sender, opponent);
+
+ // Broadcast final result.
+ var msg = Packet.Create((short)PROTOCOL.PLAYER_MOVED);
+ msg.Push(sender.PlayerIndex); // Who moved
+ msg.Push(begin_pos); // From where
+ msg.Push(target_pos); // To where
+ _room.Broadcast(msg);
+ }
+
+ ///
+ public void OnEnter() => BattleStart();
+
+ ///
+ public void OnExit()
+ {
+ }
+
+ ///
+ /// Called when the client finishes all turn animations.
+ ///
+ /// The player who finished the turn.
+ /// The packet containing the turn finished request.
+ public void TurnFinished(Player sender, Packet msg)
+ {
+ if (!_room.AllReceived(PROTOCOL.TURN_FINISHED_REQ))
+ {
+ return;
+ }
+
+ // Advance to next turn.
+ TurnEnd();
+ }
+
+ ///
+ /// Starts the game.
+ ///
+ private void BattleStart()
+ {
+ // Reset data required for each new game start.
+ _room.Reset();
+ ResetGameData();
+
+ _room.EachPlayer(player =>
+ {
+ // Send game-start message.
+ var msg = Packet.Create((short)PROTOCOL.GAME_START);
+
+ // Current player's own index.
+ msg.Push(player.PlayerIndex);
+
+ // Send all players' virus positions.
+ msg.Push((byte)_room.GetPlayerCount());
+ _room.EachPlayer(p =>
+ {
+ msg.Push(p.PlayerIndex); // Player index used for identification.
+
+ // Total number of viruses owned by this player.
+ var cell_count = (byte)p.Viruses.Count;
+ msg.Push(cell_count);
+ // Position data for this player's viruses.
+ p.Viruses.ForEach(position => msg.PushInt16(position));
+ });
+
+ // Player index that takes the first turn.
+ msg.Push(_room.GetCurrentPlayer().PlayerIndex);
+
+ player.Send(msg);
+ });
+ }
+
+ private void GameOver()
+ {
+ var count_1p = _room.GetPlayer(0).GetVirusCount();
+ var count_2p = _room.GetPlayer(1).GetVirusCount();
+
+ // Determine winner.
+ byte win_player_index;
+ if (count_1p == count_2p)
+ {
+ // Tie case.
+ win_player_index = byte.MaxValue;
+ }
+ else
+ {
+ win_player_index = count_1p > count_2p ? _room.GetPlayer(0).PlayerIndex : _room.GetPlayer(1).PlayerIndex;
+ }
+
+ var msg = Packet.Create((short)PROTOCOL.GAME_OVER);
+ msg.Push(win_player_index);
+ msg.Push(count_1p);
+ msg.Push(count_2p);
+ _room.Broadcast(msg);
+
+ _room.RemoveSelf();
+ }
+
+ ///
+ /// Places a player's virus on the board.
+ ///
+ ///
+ ///
+ ///
+ private void PutVirus(byte playerIndex, byte row, byte col)
+ {
+ var position = Helper.GetPosition(row, col);
+ PutVirus(playerIndex, position);
+ }
+
+ ///
+ /// Places a player's virus on the board.
+ ///
+ ///
+ ///
+ private void PutVirus(byte playerIndex, short position)
+ {
+ _gameBoard[position] = playerIndex;
+ _room.GetPlayer(playerIndex).AddCell(position);
+ }
+
+ ///
+ /// Removes a placed virus.
+ ///
+ ///
+ ///
+ private void RemoveVirus(byte playerIndex, short position)
+ {
+ _gameBoard[position] = _emptySlot;
+ _room.GetPlayer(playerIndex).RemoveCell(position);
+ }
+
+ ///
+ /// Resets game data needed whenever a new game starts.
+ ///
+ private void ResetGameData()
+ {
+ // Reset player data.
+ _room.EachPlayer(player => player.Reset());
+
+ // Reset board data.
+ for (var i = 0; i < _gameBoard.Count; ++i)
+ {
+ _gameBoard[i] = _emptySlot;
+ }
+ // Place player 1 viruses at top-left (0,0) and top-right (0,6).
+ PutVirus(0, 0, 0);
+ PutVirus(0, 0, 6);
+ // Place player 2 viruses at bottom-left (6,0) and bottom-right (6,6).
+ PutVirus(1, 6, 0);
+ PutVirus(1, 6, 6);
+ }
+
+ ///
+ /// Notifies clients to start the turn.
+ ///
+ private void StartTurn()
+ {
+ var msg = Packet.Create((short)PROTOCOL.START_PLAYER_TURN);
+ msg.Push(_room.GetCurrentPlayer().PlayerIndex);
+ _room.Broadcast(msg);
+ }
+
+ ///
+ /// Ends the turn and checks whether the game has finished.
+ ///
+ private void TurnEnd()
+ {
+ // Check board state to determine whether the game is over.
+ if (!Helper.CanPlayMore(_tableBoard, _room.GetOpponentPlayer(), _room.GetPlayers()))
+ {
+ GameOver();
+ return;
+ }
+
+ // If the game is not over, pass turn to the next player.
+ _room.TurnNext();
+
+ // Start the turn.
+ StartTurn();
+ }
+}
diff --git a/viruswar/server/GameServer/RoomState/GameRoomReadyState.cs b/viruswar/server/GameServer/RoomState/GameRoomReadyState.cs
new file mode 100644
index 0000000..dc0c7b3
--- /dev/null
+++ b/viruswar/server/GameServer/RoomState/GameRoomReadyState.cs
@@ -0,0 +1,42 @@
+using FreeNet;
+using GameServer.State;
+
+namespace GameServer.RoomState;
+
+///
+/// Represents the state of a game room when it is ready to start. Implements the
+///
+///
+internal class GameRoomReadyState : IState
+{
+ private readonly GameRoom _room;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The room.
+ public GameRoomReadyState(GameRoom room)
+ {
+ _room = room;
+
+ _room.StateManager.RegisterMessageHandler(this, PROTOCOL.READY_TO_START, OnReadyReq);
+ }
+
+ ///
+ public void OnEnter()
+ {
+ }
+
+ ///
+ public void OnExit()
+ {
+ }
+
+ private void OnReadyReq(Player sender, Packet message)
+ {
+ if (_room.AllReceived(PROTOCOL.READY_TO_START))
+ {
+ _room.StateManager.ChangeState(GameRoom.STATE.PLAY);
+ }
+ }
+}
diff --git a/viruswar/server/GameServer/RoomState/IRoomState.cs b/viruswar/server/GameServer/RoomState/IRoomState.cs
index 55e31c3..aaa4c7f 100644
--- a/viruswar/server/GameServer/RoomState/IRoomState.cs
+++ b/viruswar/server/GameServer/RoomState/IRoomState.cs
@@ -1,15 +1,17 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
+using FreeNet;
-namespace GameServer.RoomState
-{
- using FreeNet;
+namespace GameServer.RoomState;
- public interface IRoomState
- {
- void on_receive(PROTOCOL protocol, CPlayer owner, CPacket msg);
- }
+///
+/// Interface for room state management in a game server.
+///
+public interface IRoomState
+{
+ ///
+ /// Called when a message is received from a player in the room.
+ ///
+ /// The protocol.
+ /// The owner.
+ /// The message.
+ void OnReceive(PROTOCOL protocol, Player owner, Packet message);
}
diff --git a/viruswar/server/GameServer/State/CMessageDispatcher.cs b/viruswar/server/GameServer/State/CMessageDispatcher.cs
deleted file mode 100644
index 3e40235..0000000
--- a/viruswar/server/GameServer/State/CMessageDispatcher.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-public delegate void MessageHandlerDelegate(T t1, T2 t2);
-public class CMessageDispatcher
-{
- Dictionary> handlers;
-
- public CMessageDispatcher()
- {
- this.handlers = new Dictionary>();
- }
-
-
- public void register(Enum key, MessageHandlerDelegate