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 ----------- -![viruswar](https://github.com/sunduk/FreeNet/blob/master/viruswar/client/doc/screenshot.png?raw=true) -* FreeNet라이브러리를 활용하여 Unity로 만든 온라인 멀티플레이 보드 게임 세균전. -* The VirusWar that online multiplay board game sample developed using FreeNet and Unity. - -아키텍처 및 구조   ----------- -![structure](https://github.com/sunduk/FreeNet/blob/master/documents/struct.png?raw=true) -![class structure](https://github.com/sunduk/FreeNet/blob/master/documents/class_struct.png?raw=true) -![worker](https://github.com/sunduk/FreeNet/blob/master/documents/worker_thread.png?raw=true) -![logic](https://github.com/sunduk/FreeNet/blob/master/documents/logic_thread.png?raw=true) -![send](https://github.com/sunduk/FreeNet/blob/master/documents/send.png?raw=true) - -Structure ----------- -![structure](https://github.com/sunduk/FreeNet/blob/master/documents/struct_en.png?raw=true) -![class structure](https://github.com/sunduk/FreeNet/blob/master/documents/class_struct_en.png?raw=true) -![worker](https://github.com/sunduk/FreeNet/blob/master/documents/worker_thread_en.png?raw=true) -![logic](https://github.com/sunduk/FreeNet/blob/master/documents/logic_thread_en.png?raw=true) -![send](https://github.com/sunduk/FreeNet/blob/master/documents/send_en.png?raw=true) - - -라이선스 ----------- -* 소스코드는 상업적, 비상업적 어느 용도이든 자유롭게 사용 가능 합니다. - -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. + *

send

+ * Use of double buffering queues. +* Thread Model + * IO thread packet processing method. + * Single logic thread packet processing method. +* Additional Features + * Heartbeat functionality. + +

structure

+

class structure

+

worker

+

logic

+

send

+ +--- 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. + +

Test

+ +3. Test performance by increasing the Times entries (**Caution: if increased too much, your PC may crash!!**). +--- + +## Sample Game + +--- +

viruswar

+ +* 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 fn) - { - if (!this.handlers.ContainsKey(key)) - { - this.handlers.Add(key, fn); - return; - } - - this.handlers[key] = fn; - } - - - public void unregister(Enum key) - { - this.handlers.Remove(key); - } - - - public void dispatch(Enum key, T t1, T2 t2) - { - if (!this.handlers.ContainsKey(key)) - { - return; - } - - this.handlers[key](t1, t2); - } -} diff --git a/viruswar/server/GameServer/State/CStateManager.cs b/viruswar/server/GameServer/State/CStateManager.cs deleted file mode 100644 index d6a51ca..0000000 --- a/viruswar/server/GameServer/State/CStateManager.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; - - -/// -/// 상태 매니저. -/// 각 상태들의 전환과 상태 객체에 메지시 전송 기능을 담당한다. -/// -/// T, T2는 메시지 전송시 보낼 파라미터 타입이다. -/// todo:두개로 고정되어 있는데 좀 더 유연하게 바꾸는 방법을 고민중이다. -/// -/// -/// -public class CStateManager -{ - //---------------------------------------------- - // 상태 관리. - //---------------------------------------------- - Enum current_state_type; - IState current_state; - Dictionary states = new Dictionary(); - - - //---------------------------------------------- - // 메시지 관리. - //---------------------------------------------- - Dictionary> message_dispatcher; - - - public CStateManager() - { - this.message_dispatcher = new Dictionary>(); - } - - - public void register_message_handler(IState state, Enum key, MessageHandlerDelegate fn) - { - if (!this.message_dispatcher.ContainsKey(state)) - { - this.message_dispatcher.Add(state, new CMessageDispatcher()); - } - - this.message_dispatcher[state].register(key, fn); - } - - - public void unregister_message_handler(IState state, Enum key) - { - if (!this.message_dispatcher.ContainsKey(state)) - { - return; - } - - this.message_dispatcher[state].unregister(key); - } - - - public void add(Enum key, IState state) - { - if (!this.states.ContainsKey(key)) - { - this.states.Add(key, state); - return; - } - - this.states[key] = state; - } - - - public void change_state(Enum next_state) - { - if (this.current_state != null) - { - this.current_state.on_exit(); - } - - this.current_state_type = next_state; - this.current_state = this.states[next_state]; - this.current_state.on_enter(); - } - - - /// - /// 현재 수행중인 상태 객체에 메시지를 전송한다. - /// - /// - /// - /// - public void send_state_message(System.Enum message, T t1, T2 t2) - { - if (this.current_state == null) - { - return; - } - - if (!this.message_dispatcher.ContainsKey(this.current_state)) - { - return; - } - - this.message_dispatcher[this.current_state].dispatch(message, t1, t2); - } - - - public bool is_current_state(System.Enum state) - { - return Enum.Equals(this.current_state_type, state); - } -} diff --git a/viruswar/server/GameServer/State/IState.cs b/viruswar/server/GameServer/State/IState.cs index 10a2219..ea30271 100644 --- a/viruswar/server/GameServer/State/IState.cs +++ b/viruswar/server/GameServer/State/IState.cs @@ -1,6 +1,17 @@ - +namespace GameServer.State; + +/// +/// Interface for state. +/// public interface IState { - void on_enter(); - void on_exit(); -} + /// + /// Called when [enter]. + /// + void OnEnter(); + + /// + /// Called when [exit]. + /// + void OnExit(); +} \ No newline at end of file diff --git a/viruswar/server/GameServer/State/MessageDispatcher.cs b/viruswar/server/GameServer/State/MessageDispatcher.cs new file mode 100644 index 0000000..6d7a14d --- /dev/null +++ b/viruswar/server/GameServer/State/MessageDispatcher.cs @@ -0,0 +1,28 @@ +namespace GameServer.State; + +public delegate void MessageHandlerDelegate(T t1, T2 t2); + +public class MessageDispatcher +{ + private readonly Dictionary> _handlers; + + public MessageDispatcher() => _handlers = []; + + public void Dispatch(Enum key, T t1, T2 t2) + { + if (_handlers.TryGetValue(key, out var value)) + { + value(t1, t2); + } + } + + public void Register(Enum key, MessageHandlerDelegate fn) + { + if (!_handlers.TryAdd(key, fn)) + { + _handlers[key] = fn; + } + } + + public void Unregister(Enum key) => _handlers.Remove(key); +} diff --git a/viruswar/server/GameServer/State/StateManager.cs b/viruswar/server/GameServer/State/StateManager.cs new file mode 100644 index 0000000..585cc13 --- /dev/null +++ b/viruswar/server/GameServer/State/StateManager.cs @@ -0,0 +1,84 @@ +namespace GameServer.State; + +/// +/// State manager. +/// Handles transitions between states and message dispatch to state objects. +/// T and T2 are parameter types passed with dispatched messages. +/// TODO: Currently fixed to two parameter types; consider a more flexible design. +/// +/// Type of the first message parameter. +/// Type of the second message parameter. +public class StateManager +{ + /// + /// Message dispatch table by state. + /// + private readonly Dictionary> _messageDispatcher; + + private readonly Dictionary _states = []; + private IState? _currentState; + + /// + /// Current state type. + /// + private Enum? _currentStateType; + + public StateManager() => _messageDispatcher = []; + + public void Add(Enum key, IState state) + { + if (!_states.TryAdd(key, state)) + { + _states[key] = state; + } + } + + public void ChangeState(Enum next_state) + { + _currentState?.OnExit(); + + _currentStateType = next_state; + _currentState = _states[next_state]; + _currentState.OnEnter(); + } + + public bool IsCurrentState(System.Enum state) => Enum.Equals(_currentStateType, state); + + public void RegisterMessageHandler(IState state, Enum key, MessageHandlerDelegate fn) + { + if (!_messageDispatcher.TryGetValue(state, out var value)) + { + value = new MessageDispatcher(); + _messageDispatcher.Add(state, value); + } + + value.Register(key, fn); + } + + /// + /// Sends a message to the currently active state object. + /// + /// Message to send. + /// First parameter sent with the message. + /// Second parameter sent with the message. + public void SendStateMessage(System.Enum message, T t1, T2 t2) + { + if (_currentState is null) + { + return; + } + + if (_messageDispatcher.TryGetValue(_currentState, out var value)) + { + value.Dispatch(message, t1, t2); + } + } + + public void UnregisterMessageHandler(IState state, Enum key) + { + if (_messageDispatcher.TryGetValue(state, out var value)) + { + value.Unregister(key); + } + } +} diff --git a/viruswar/server/GameServer/UserState/CUserLobbyState.cs b/viruswar/server/GameServer/UserState/CUserLobbyState.cs deleted file mode 100644 index ac36bc7..0000000 --- a/viruswar/server/GameServer/UserState/CUserLobbyState.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using FreeNet; - -namespace GameServer.UserState -{ - class CUserLobbyState : IUserState - { - CGameUser owner; - - public CUserLobbyState(CGameUser owner) - { - this.owner = owner; - } - - void IUserState.on_message(FreeNet.CPacket msg) - { - PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id(); - Console.WriteLine("protocol id " + protocol); - switch (protocol) - { - case PROTOCOL.ENTER_GAME_ROOM_REQ: - Program.game_main.matching_req(this.owner); - break; - } - } - } -} diff --git a/viruswar/server/GameServer/UserState/CUserPlayState.cs b/viruswar/server/GameServer/UserState/CUserPlayState.cs deleted file mode 100644 index f3d02ff..0000000 --- a/viruswar/server/GameServer/UserState/CUserPlayState.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace GameServer.UserState -{ - class CUserPlayState : IUserState - { - CGameUser owner; - - public CUserPlayState(CGameUser owner) - { - this.owner = owner; - } - - - void IUserState.on_message(FreeNet.CPacket msg) - { - // 플레이중 수신된 모든 메시지는 룸으로 넘겨서 처리한다. - this.owner.battle_room.on_receive(this.owner.player, msg); - } - } -} diff --git a/viruswar/server/GameServer/UserState/IUserState.cs b/viruswar/server/GameServer/UserState/IUserState.cs index 705be14..abc90d1 100644 --- a/viruswar/server/GameServer/UserState/IUserState.cs +++ b/viruswar/server/GameServer/UserState/IUserState.cs @@ -1,21 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using FreeNet; -namespace GameServer.UserState -{ - using FreeNet; - - public enum USER_STATE_TYPE - { - LOBBY, - PLAY - } +namespace GameServer.UserState; - interface IUserState - { - void on_message(CPacket msg); - } -} +/// +/// The IUserState interface defines the contract for user states in the game server. Each user state must implement the +/// OnMessage method to handle incoming messages from the client. +/// +internal interface IUserState +{ + /// + /// Called when a message is received from the client. + /// + /// The message. + void OnMessage(Packet message); +} \ No newline at end of file diff --git a/viruswar/server/GameServer/UserState/UserLobbyState.cs b/viruswar/server/GameServer/UserState/UserLobbyState.cs new file mode 100644 index 0000000..5b097cb --- /dev/null +++ b/viruswar/server/GameServer/UserState/UserLobbyState.cs @@ -0,0 +1,17 @@ +namespace GameServer.UserState; + +internal class UserLobbyState(GameUser owner) : IUserState +{ + /// + public void OnMessage(FreeNet.Packet msg) + { + var protocol = (PROTOCOL)msg.PopProtocolId(); + Console.WriteLine($"protocol id {protocol}"); + switch (protocol) + { + case PROTOCOL.ENTER_GAME_ROOM_REQ: + Program.GameMain.MatchingReq(owner); + break; + } + } +} diff --git a/viruswar/server/GameServer/UserState/UserPlayState.cs b/viruswar/server/GameServer/UserState/UserPlayState.cs new file mode 100644 index 0000000..e25ee07 --- /dev/null +++ b/viruswar/server/GameServer/UserState/UserPlayState.cs @@ -0,0 +1,11 @@ +namespace GameServer.UserState; + +internal class UserPlayState(GameUser owner) : IUserState +{ + /// + /// While playing, forward all received messages to the room for processing. + /// + /// The message. + void IUserState.OnMessage(FreeNet.Packet message) => + owner.BattleRoom?.OnReceive(owner.Player, message); +} diff --git a/viruswar/server/GameServer/UserState/UserStateType.cs b/viruswar/server/GameServer/UserState/UserStateType.cs new file mode 100644 index 0000000..34156c4 --- /dev/null +++ b/viruswar/server/GameServer/UserState/UserStateType.cs @@ -0,0 +1,17 @@ +namespace GameServer.UserState; + +/// +/// Defines the different states a user can be in within the game server. +/// +public enum UserStateType +{ + /// + /// The lobby + /// + Lobby, + + /// + /// The play + /// + Play +} diff --git a/viruswar/server/GameServer/Vector2.cs b/viruswar/server/GameServer/Vector2.cs deleted file mode 100644 index 7a1e607..0000000 --- a/viruswar/server/GameServer/Vector2.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace GameServer -{ - public struct Vector2 - { - public float x; - public float y; - - public Vector2(float x, float y) - { - this.x = x; - this.y = y; - } - - public static Vector2 operator -(Vector2 a, Vector2 b) - { - return new Vector2(a.x - b.x, a.y - b.y); - } - } -} diff --git a/viruswar/server/GameServer/protocol.cs b/viruswar/server/GameServer/protocol.cs index 822bd7a..08e453d 100644 --- a/viruswar/server/GameServer/protocol.cs +++ b/viruswar/server/GameServer/protocol.cs @@ -1,64 +1,57 @@ -using System; +namespace GameServer; -namespace GameServer +/// +/// Protocol definitions. +/// Packets from server to client: S -> C. Packets from client to server: C -> S. +/// +public enum PROTOCOL : short { - /// - /// 프로토콜 정의. - /// 서버에서 클라이언트로 가는 패킷 : S -> C - /// 클라이언트에서 서버로 가는 패킷 : C -> S - /// - public enum PROTOCOL : short - { - //------------------------------------- - // 0 이하는 종료코드로 사용되므로 게임에서 쓰지 말것!! - //------------------------------------- - BEGIN = 0, + //------------------------------------- + // Values <= 0 are reserved as termination/system codes. Do not use for game protocols. + //------------------------------------- + BEGIN = 0, + //------------------------------------- + // Lobby protocols. + //------------------------------------- + // C -> S game room enter request. + ENTER_GAME_ROOM_REQ = 1, - //------------------------------------- - // 로비 프로토콜. - //------------------------------------- - // C -> S 게임방 입장 요청. - ENTER_GAME_ROOM_REQ = 1, + // S -> C response to game room enter request. + ENTER_GAME_ROOM_ACK = 2, - // S -> C 게임장 입장 요청에 대한 응답. - ENTER_GAME_ROOM_ACK = 2, + // S -> C matching succeeded. Enter room and start loading. + START_LOADING = 3, - // S -> C 매칭이 성공했다. 방에 입장하고 로딩을 시작해라. - START_LOADING = 3, + // Concurrent user count request/response. + CONCURRENT_USERS = 4, - // 동시 접속자 정보 요청/응답. - CONCURRENT_USERS = 4, + //------------------------------------- + // Game protocols. + //------------------------------------- + // C -> S game-room resource loading complete. Ready to start game. + READY_TO_START = 10, + // Game start. + GAME_START = 11, + // Start turn. + START_PLAYER_TURN = 12, - //------------------------------------- - // 게임 프로토콜. - //------------------------------------- - // C -> S 게임방 리소스 로딩을 완료했다. 게임을 시작해도 좋다. - READY_TO_START = 10, + // Client move request. + MOVING_REQ = 13, - // 게임 시작. - GAME_START = 11, + // Notify that a player moved. + PLAYER_MOVED = 14, - // 턴 시작. - START_PLAYER_TURN = 12, + // Notify that the client's turn animation is finished. + TURN_FINISHED_REQ = 15, - // 클라이언트의 이동 요청. - MOVING_REQ = 13, + // Game over. + GAME_OVER = 16, - // 플레이어가 이동 했음을 알린다. - PLAYER_MOVED = 14, + // Room removed. + ROOM_REMOVED = 17, - // 클라이언트의 턴 연출이 끝났음을 알린다. - TURN_FINISHED_REQ = 15, - - // 게임 종료. - GAME_OVER = 16, - - // 방이 삭제됨. - ROOM_REMOVED = 17, - - END - } + END } diff --git a/viruswar/server/viruswar_server.sln b/viruswar/server/viruswar_server.sln deleted file mode 100644 index db2fcf6..0000000 --- a/viruswar/server/viruswar_server.sln +++ /dev/null @@ -1,22 +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}") = "GameServer", "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 - {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 -EndGlobal diff --git a/viruswar/server/viruswar_server.slnx b/viruswar/server/viruswar_server.slnx new file mode 100644 index 0000000..b3fa7cd --- /dev/null +++ b/viruswar/server/viruswar_server.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/workload-install.ps1 b/workload-install.ps1 new file mode 100644 index 0000000..e977f8b --- /dev/null +++ b/workload-install.ps1 @@ -0,0 +1,335 @@ +# +# Copyright (c) Samsung Electronics. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +<# +.SYNOPSIS +Installs Tizen workload manifest. +.DESCRIPTION +Installs the WorkloadManifest.json and WorkloadManifest.targets files for Tizen to the dotnet sdk. +.PARAMETER Version +Use specific VERSION +.PARAMETER DotnetInstallDir +Dotnet SDK Location installed +#> + +[cmdletbinding()] +param( + [Alias('v')][string]$Version="", + [Alias('d')][string]$DotnetInstallDir="", + [Alias('t')][string]$DotnetTargetVersionBand="", + [Alias('u')][switch]$UpdateAllWorkloads +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$ManifestBaseName = "Samsung.NET.Sdk.Tizen.Manifest" +$global:FallbackId = "" + +# BEGIN AUTO-GENERATED VERSION MAP -- edit version-map.json and rerun Generate-InstallScripts.ps1 +$LatestVersionMap = [ordered]@{ + "$ManifestBaseName-6.0.100" = "7.0.101"; + "$ManifestBaseName-6.0.200" = "7.0.100-preview.13.6"; + "$ManifestBaseName-6.0.300" = "8.0.133"; + "$ManifestBaseName-6.0.400" = "9.0.104"; + "$ManifestBaseName-7.0.100-preview.6" = "7.0.100-preview.6.14"; + "$ManifestBaseName-7.0.100-preview.7" = "7.0.100-preview.7.20"; + "$ManifestBaseName-7.0.100-rc.1" = "7.0.100-rc.1.22"; + "$ManifestBaseName-7.0.100-rc.2" = "7.0.100-rc.2.24"; + "$ManifestBaseName-7.0.100" = "7.0.103"; + "$ManifestBaseName-7.0.200" = "7.0.105"; + "$ManifestBaseName-7.0.300" = "7.0.120"; + "$ManifestBaseName-7.0.400" = "10.0.119"; + "$ManifestBaseName-8.0.100-alpha.1" = "7.0.104"; + "$ManifestBaseName-8.0.100-preview.2" = "7.0.106"; + "$ManifestBaseName-8.0.100-preview.3" = "7.0.107"; + "$ManifestBaseName-8.0.100-preview.4" = "7.0.108"; + "$ManifestBaseName-8.0.100-preview.5" = "7.0.110"; + "$ManifestBaseName-8.0.100-preview.6" = "7.0.121"; + "$ManifestBaseName-8.0.100-preview.7" = "7.0.122"; + "$ManifestBaseName-8.0.100-rc.1" = "7.0.124"; + "$ManifestBaseName-8.0.100-rc.2" = "7.0.125"; + "$ManifestBaseName-8.0.100-rtm" = "7.0.127"; + "$ManifestBaseName-8.0.100" = "8.0.144"; + "$ManifestBaseName-8.0.200" = "8.0.157"; + "$ManifestBaseName-8.0.300" = "8.0.156"; + "$ManifestBaseName-8.0.400" = "10.0.120"; + "$ManifestBaseName-9.0.100-alpha.1" = "8.0.134"; + "$ManifestBaseName-9.0.100-preview.1" = "8.0.135"; + "$ManifestBaseName-9.0.100-preview.2" = "8.0.137"; + "$ManifestBaseName-9.0.100" = "10.0.104"; + "$ManifestBaseName-9.0.200" = "10.0.110"; + "$ManifestBaseName-9.0.300" = "10.0.121"; + "$ManifestBaseName-10.0.100" = "10.0.123" +} +# END AUTO-GENERATED VERSION MAP + +function New-TemporaryDirectory { + $parent = [System.IO.Path]::GetTempPath() + $name = [System.IO.Path]::GetRandomFileName() + New-Item -ItemType Directory -Path (Join-Path $parent $name) +} + +function Ensure-Directory([string]$TestDir) { + Try { + New-Item -ItemType Directory -Path $TestDir -Force -ErrorAction stop | Out-Null + [io.file]::OpenWrite($(Join-Path -Path $TestDir -ChildPath ".test-write-access")).Close() + Remove-Item -Path $(Join-Path -Path $TestDir -ChildPath ".test-write-access") -Force + } + Catch [System.UnauthorizedAccessException] { + Write-Error "No permission to install. Try run with administrator mode." + } +} + +function Get-LatestVersion([string]$Id) { + $attempts=3 + $sleepInSeconds=3 + do + { + try + { + $Response = Invoke-WebRequest -Uri https://api.nuget.org/v3-flatcontainer/$($Id.ToLowerInvariant())/index.json -UseBasicParsing | ConvertFrom-Json + return $Response.versions | Select-Object -Last 1 + } + catch { + Write-Host "Id: $Id" + Write-Host "An exception was caught: $($_.Exception.Message)" + } + + $attempts-- + if ($attempts -gt 0) { Start-Sleep $sleepInSeconds } + } while ($attempts -gt 0) + + if ($LatestVersionMap.Contains($Id)) + { + Write-Host "Return cached latest version." + return $LatestVersionMap.$Id + } + else + { + $SubStringId = $Id.Substring(0, $ManifestBaseName.Length + 2); + $MatchingFallbackId = @() + $MatchingFallbackVersion = @() + foreach ($key in $LatestVersionMap.Keys) { + if ($key -like "$SubStringId*") { + $MatchingFallbackId += $key + $MatchingFallbackVersion += $LatestVersionMap[$key] + } + } + if ($MatchingFallbackVersion) + { + $global:FallbackId = $MatchingFallbackId[-1] + $FallbackVersion = $MatchingFallbackVersion[-1] + Write-Host "Return fallback version: $FallbackVersion" + return $FallbackVersion + } + } + + Write-Error "Wrong Id: $Id" +} + +function Get-Package([string]$Id, [string]$Version, [string]$Destination, [string]$FileExt = "nupkg") { + $OutFileName = "$Id.$Version.$FileExt" + $OutFilePath = Join-Path -Path $Destination -ChildPath $OutFileName + + if ($Id -match ".net[0-9]+$") { + $Id = $Id -replace (".net[0-9]+", "") + } + + Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/$Id/$Version" -OutFile $OutFilePath + + return $OutFilePath +} + +function Install-Pack([string]$Id, [string]$Version, [string]$Kind) { + $TempZipFile = $(Get-Package -Id $Id -Version $Version -Destination $TempDir -FileExt "zip") + $TempUnzipDir = Join-Path -Path $TempDir -ChildPath "unzipped\$Id" + + switch ($Kind) { + "manifest" { + Expand-Archive -Path $TempZipFile -DestinationPath $TempUnzipDir + New-Item -Path $TizenManifestDir -ItemType "directory" -Force | Out-Null + Copy-Item -Path "$TempUnzipDir\data\*" -Destination $TizenManifestDir -Force + } + {($_ -eq "sdk") -or ($_ -eq "framework")} { + Expand-Archive -Path $TempZipFile -DestinationPath $TempUnzipDir + if ( ($kind -eq "sdk") -and ($Id -match ".net[0-9]+$")) { + $Id = $Id -replace (".net[0-9]+", "") + } + $TargetDirectory = $(Join-Path -Path $DotnetInstallDir -ChildPath "packs\$Id\$Version") + New-Item -Path $TargetDirectory -ItemType "directory" -Force | Out-Null + Copy-Item -Path "$TempUnzipDir/*" -Destination $TargetDirectory -Recurse -Force + } + "template" { + $TargetFileName = "$Id.$Version.nupkg".ToLower() + $TargetDirectory = $(Join-Path -Path $DotnetInstallDir -ChildPath "template-packs") + New-Item -Path $TargetDirectory -ItemType "directory" -Force | Out-Null + Copy-Item $TempZipFile -Destination $(Join-Path -Path $TargetDirectory -ChildPath "$TargetFileName") -Force + } + } +} + +function Remove-Pack([string]$Id, [string]$Version, [string]$Kind) { + switch ($Kind) { + "manifest" { + Remove-Item -Path $TizenManifestDir -Recurse -Force + } + {($_ -eq "sdk") -or ($_ -eq "framework")} { + $TargetDirectory = $(Join-Path -Path $DotnetInstallDir -ChildPath "packs\$Id\$Version") + Remove-Item -Path $TargetDirectory -Recurse -Force + } + "template" { + $TargetFileName = "$Id.$Version.nupkg".ToLower(); + Remove-Item -Path $(Join-Path -Path $DotnetInstallDir -ChildPath "template-packs\$TargetFileName") -Force + } + } +} + +function Install-TizenWorkload([string]$DotnetVersion) +{ + $VersionSplitSymbol = '.' + $SplitVersion = $DotnetVersion.Split($VersionSplitSymbol) + + $CurrentDotnetVersion = [Version]"$($SplitVersion[0]).$($SplitVersion[1])" + $DotnetVersionBand = $SplitVersion[0] + $VersionSplitSymbol + $SplitVersion[1] + $VersionSplitSymbol + $SplitVersion[2][0] + "00" + $ManifestName = "$ManifestBaseName-$DotnetVersionBand" + + if ($DotnetTargetVersionBand -eq "" -or $UpdateAllWorkloads.IsPresent) { + if ($CurrentDotnetVersion -ge "7.0") + { + $IsPreviewVersion = $DotnetVersion.Contains("-preview") -or $DotnetVersion.Contains("-rc") -or $DotnetVersion.Contains("-alpha") + if ($IsPreviewVersion -and ($SplitVersion.Count -ge 4)) { + $DotnetTargetVersionBand = $DotnetVersionBand + $SplitVersion[2].SubString(3) + $VersionSplitSymbol + $($SplitVersion[3]) + $ManifestName = "$ManifestBaseName-$DotnetTargetVersionBand" + } + elseif ($DotnetVersion.Contains("-rtm") -and ($SplitVersion.Count -ge 3)) { + $DotnetTargetVersionBand = $DotnetVersionBand + $SplitVersion[2].SubString(3) + $ManifestName = "$ManifestBaseName-$DotnetTargetVersionBand" + } + else { + $DotnetTargetVersionBand = $DotnetVersionBand + } + } + else { + $DotnetTargetVersionBand = $DotnetVersionBand + } + } + + # Check latest version of manifest. + if ($Version -eq "" -or $UpdateAllWorkloads.IsPresent) { + $Version = Get-LatestVersion -Id $ManifestName + } + + # Check workload manifest directory. + $ManifestDir = Join-Path -Path $DotnetInstallDir -ChildPath "sdk-manifests" | Join-Path -ChildPath $DotnetTargetVersionBand + $TizenManifestDir = Join-Path -Path $ManifestDir -ChildPath "samsung.net.sdk.tizen" + $TizenManifestFile = Join-Path -Path $TizenManifestDir -ChildPath "WorkloadManifest.json" + + # Check and remove already installed old version. + if (Test-Path $TizenManifestFile) { + $ManifestJson = $(Get-Content $TizenManifestFile | ConvertFrom-Json) + $OldVersion = $ManifestJson.version + if ($OldVersion -eq $Version) { + $DotnetWorkloadList = Invoke-Expression "& '$DotnetCommand' workload list | Select-String -Pattern '^tizen'" + if ($DotnetWorkloadList) + { + Write-Host "Tizen Workload $Version version is already installed." + Continue + } + } + + Ensure-Directory $ManifestDir + Write-Host "Removing $ManifestName/$OldVersion from $ManifestDir..." + Remove-Pack -Id $ManifestName -Version $OldVersion -Kind "manifest" + $ManifestJson.packs.PSObject.Properties | ForEach-Object { + Write-Host "Removing $($_.Name)/$($_.Value.version)..." + Remove-Pack -Id $_.Name -Version $_.Value.version -Kind $_.Value.kind + } + } + + Ensure-Directory $ManifestDir + $TempDir = $(New-TemporaryDirectory) + + # Install workload manifest. + Write-Host "Installing $ManifestName/$Version to $ManifestDir..." + if ($global:FallbackId) { + Install-Pack -Id $global:FallbackId -Version $Version -Kind "manifest" + } else { + Install-Pack -Id $ManifestName -Version $Version -Kind "manifest" + } + + # Download and install workload packs. + $NewManifestJson = $(Get-Content $TizenManifestFile | ConvertFrom-Json) + $NewManifestJson.packs.PSObject.Properties | ForEach-Object { + Write-Host "Installing $($_.Name)/$($_.Value.version)..." + Install-Pack -Id $_.Name -Version $_.Value.version -Kind $_.Value.kind + } + + # Add tizen to the installed workload metadata. + # Featured version band for metadata does NOT include any preview specifier. + # https://github.com/dotnet/sdk/blob/main/documentation/general/workloads/user-local-workloads.md + New-Item -Path $(Join-Path -Path $DotnetInstallDir -ChildPath "metadata\workloads\$DotnetVersionBand\InstalledWorkloads\tizen") -Force | Out-Null + if (Test-Path $(Join-Path -Path $DotnetInstallDir -ChildPath "metadata\workloads\$DotnetVersionBand\InstallerType\msi")) { + New-Item -Path "HKLM:\SOFTWARE\Microsoft\dotnet\InstalledWorkloads\Standalone\x64\$DotnetTargetVersionBand\tizen" -Force | Out-Null + } + + # Clean up + Remove-Item -Path $TempDir -Force -Recurse + + Write-Host "Done installing Tizen workload $Version" +} + +# Check dotnet install directory. +if ($DotnetInstallDir -eq "") { + if ($Env:DOTNET_ROOT -And $(Test-Path "$Env:DOTNET_ROOT")) { + $DotnetInstallDir = $Env:DOTNET_ROOT + } else { + $DotnetInstallDir = Join-Path -Path $Env:Programfiles -ChildPath "dotnet" + } +} +if (-Not $(Test-Path "$DotnetInstallDir")) { + Write-Error "No installed dotnet '$DotnetInstallDir'." +} + +# Check installed dotnet version +$DotnetCommand = "$DotnetInstallDir\dotnet" +if (Get-Command $DotnetCommand -ErrorAction SilentlyContinue) +{ + if ($UpdateAllWorkloads.IsPresent) + { + $InstalledDotnetSdks = Invoke-Expression "& '$DotnetCommand' --list-sdks | Select-String -Pattern '^6|^7|^8|^9|^10'" | ForEach-Object {$_ -replace (" \[.*","")} + } + else + { + $InstalledDotnetSdks = Invoke-Expression "& '$DotnetCommand' --version" + } +} +else +{ + Write-Error "'$DotnetCommand' occurs an error." +} + +if (-Not $InstalledDotnetSdks) +{ + Write-Host "`n.NET SDK version 6 or later is required to install Tizen Workload." +} +else +{ + foreach ($DotnetSdk in $InstalledDotnetSdks) + { + try { + Write-Host "`nCheck Tizen Workload for sdk $DotnetSdk" + Install-TizenWorkload -DotnetVersion $DotnetSdk + } + catch { + Write-Host "Failed to install Tizen Workload for sdk $DotnetSdk" + Write-Host "$_" + Continue + } + } +} + +Write-Host "`nDone"