From ccea2deeba843bd6b1ae7662cbd87a671d52a3ce Mon Sep 17 00:00:00 2001 From: Sky Date: Sun, 1 Oct 2023 20:33:55 -0700 Subject: [PATCH] Add base party implementation (#58) --- .editorconfig | 164 ++++++++ Maple2.Model/Error/PartyError.cs | 96 +++++ Maple2.Model/Game/Party/Party.cs | 64 ++++ Maple2.Model/Game/Party/PartyInvite.cs | 9 + Maple2.Model/Game/Party/PartyMember.cs | 91 +++++ Maple2.Model/Game/User/Character.cs | 1 + Maple2.Model/Metadata/Constants.cs | 2 + .../Sync/PlayerInfoUpdateEvent.cs | 1 + .../proto/channel/channel.proto | 43 +++ Maple2.Server.Core/proto/common.proto | 2 +- Maple2.Server.Core/proto/world/world.proto | 79 ++++ Maple2.Server.Game/Manager/PartyManager.cs | 240 ++++++++++++ .../PacketHandlers/PartyHandler.cs | 257 +++++++++++++ .../PacketHandlers/UserChatHandler.cs | 4 +- Maple2.Server.Game/Packets/PartyPacket.cs | 355 ++++++++++++++++++ .../Service/ChannelService.Party.cs | 131 +++++++ Maple2.Server.Game/Session/GameSession.cs | 12 +- Maple2.Server.World/Containers/PartyLookup.cs | 79 ++++ .../Containers/PartyManager.cs | 217 +++++++++++ Maple2.Server.World/Program.cs | 2 + .../Service/WorldService.Chat.cs | 4 +- .../Service/WorldService.Party.cs | 174 +++++++++ Maple2.Server.World/Service/WorldService.cs | 5 +- 23 files changed, 2024 insertions(+), 8 deletions(-) create mode 100644 .editorconfig create mode 100644 Maple2.Model/Error/PartyError.cs create mode 100644 Maple2.Model/Game/Party/Party.cs create mode 100644 Maple2.Model/Game/Party/PartyInvite.cs create mode 100644 Maple2.Model/Game/Party/PartyMember.cs create mode 100644 Maple2.Server.Game/Manager/PartyManager.cs create mode 100644 Maple2.Server.Game/PacketHandlers/PartyHandler.cs create mode 100644 Maple2.Server.Game/Packets/PartyPacket.cs create mode 100644 Maple2.Server.Game/Service/ChannelService.Party.cs create mode 100644 Maple2.Server.World/Containers/PartyLookup.cs create mode 100644 Maple2.Server.World/Containers/PartyManager.cs create mode 100644 Maple2.Server.World/Service/WorldService.Party.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..18f7c1a5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,164 @@ + +[*] +charset = utf-8-bom +end_of_line = crlf +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +# Microsoft .NET properties +csharp_new_line_before_catch = false +csharp_new_line_before_else = false +csharp_new_line_before_finally = false +csharp_new_line_before_open_brace = none +csharp_new_line_between_query_expression_clauses = false +csharp_preferred_modifier_order = protected, public, override, private, new, internal, static, async, virtual, sealed, abstract, extern, unsafe, volatile, readonly, required:suggestion +csharp_space_after_cast = true +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_var_elsewhere = false:none +csharp_style_var_for_built_in_types = false:suggestion +dotnet_naming_rule.constants_rule.import_to_resharper = as_predefined +dotnet_naming_rule.constants_rule.severity = warning +dotnet_naming_rule.constants_rule.style = all_upper_style +dotnet_naming_rule.constants_rule.symbols = constants_symbols +dotnet_naming_rule.private_constants_rule.import_to_resharper = as_predefined +dotnet_naming_rule.private_constants_rule.severity = warning +dotnet_naming_rule.private_constants_rule.style = upper_camel_case_style +dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols +dotnet_naming_rule.private_instance_fields_rule.import_to_resharper = as_predefined +dotnet_naming_rule.private_instance_fields_rule.severity = warning +dotnet_naming_rule.private_instance_fields_rule.style = lower_camel_case_style +dotnet_naming_rule.private_instance_fields_rule.symbols = private_instance_fields_symbols +dotnet_naming_rule.private_static_fields_rule.import_to_resharper = as_predefined +dotnet_naming_rule.private_static_fields_rule.severity = warning +dotnet_naming_rule.private_static_fields_rule.style = lower_camel_case_style_1 +dotnet_naming_rule.private_static_fields_rule.symbols = private_static_fields_symbols +dotnet_naming_rule.private_static_readonly_rule.import_to_resharper = as_predefined +dotnet_naming_rule.private_static_readonly_rule.severity = warning +dotnet_naming_rule.private_static_readonly_rule.style = upper_camel_case_style +dotnet_naming_rule.private_static_readonly_rule.symbols = private_static_readonly_symbols +dotnet_naming_rule.unity_serialized_field_rule.import_to_resharper = True +dotnet_naming_rule.unity_serialized_field_rule.resharper_description = Unity serialized field +dotnet_naming_rule.unity_serialized_field_rule.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef +dotnet_naming_rule.unity_serialized_field_rule.severity = warning +dotnet_naming_rule.unity_serialized_field_rule.style = lower_camel_case_style +dotnet_naming_rule.unity_serialized_field_rule.symbols = unity_serialized_field_symbols +dotnet_naming_style.all_upper_style.capitalization = all_upper +dotnet_naming_style.all_upper_style.word_separator = _ +dotnet_naming_style.lower_camel_case_style.capitalization = camel_case +dotnet_naming_style.lower_camel_case_style_1.capitalization = camel_case +dotnet_naming_style.lower_camel_case_style_1.required_prefix = _ +dotnet_naming_style.upper_camel_case_style.capitalization = pascal_case +dotnet_naming_symbols.constants_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.constants_symbols.applicable_kinds = field +dotnet_naming_symbols.constants_symbols.required_modifiers = const +dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field +dotnet_naming_symbols.private_constants_symbols.required_modifiers = const +dotnet_naming_symbols.private_instance_fields_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_instance_fields_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_symbols.required_modifiers = static +dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static,readonly +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = * +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field +dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# ReSharper properties +resharper_align_linq_query = true +resharper_align_multiline_parameter = true +resharper_allow_comment_after_lbrace = true +resharper_arguments_skip_single = true +resharper_autodetect_indent_settings = true +resharper_blank_lines_after_block_statements = 0 +resharper_blank_lines_around_auto_property = 0 +resharper_blank_lines_around_property = 0 +resharper_braces_for_for = required +resharper_braces_for_foreach = required_for_multiline_statement +resharper_braces_for_ifelse = not_required +resharper_braces_for_lock = not_required +resharper_braces_for_while = required +resharper_braces_redundant = false +resharper_csharp_blank_lines_around_field = 0 +resharper_csharp_blank_lines_around_invocable = 0 +resharper_csharp_blank_lines_around_region = 0 +resharper_csharp_blank_lines_inside_region = 0 +resharper_csharp_insert_final_newline = true +resharper_csharp_max_line_length = 359 +resharper_csharp_remove_blank_lines_near_braces_in_code = false +resharper_csharp_remove_blank_lines_near_braces_in_declarations = false +resharper_csharp_wrap_before_binary_opsign = true +resharper_csharp_wrap_ternary_expr_style = wrap_if_long +resharper_formatter_off_tag = @formatter:off +resharper_formatter_on_tag = @formatter:on +resharper_formatter_tags_enabled = true +resharper_instance_members_qualify_declared_in = +resharper_keep_existing_attribute_arrangement = true +resharper_keep_existing_declaration_block_arrangement = true +resharper_keep_existing_embedded_block_arrangement = true +resharper_keep_existing_enum_arrangement = true +resharper_keep_existing_initializer_arrangement = false +resharper_modifiers_order = protected public override private new internal static async virtual sealed abstract extern unsafe volatile readonly required +resharper_outdent_statement_labels = true +resharper_parentheses_non_obvious_operations = none, bitwise_and, bitwise_exclusive_or, bitwise_inclusive_or, bitwise, conditional_and +resharper_parentheses_redundancy_style = remove +resharper_place_expr_accessor_on_single_line = true +resharper_place_expr_method_on_single_line = true +resharper_place_expr_property_on_single_line = true +resharper_place_field_attribute_on_same_line = if_owner_is_single_line +resharper_place_record_field_attribute_on_same_line = true +resharper_place_simple_anonymousmethod_on_single_line = false +resharper_place_simple_embedded_statement_on_same_line = true +resharper_place_simple_initializer_on_single_line = false +resharper_space_within_single_line_array_initializer_braces = false +resharper_trailing_comma_in_multiline_lists = true +resharper_use_indent_from_vs = false +resharper_wrap_array_initializer_style = chop_always +resharper_wrap_object_and_collection_initializer_style = chop_always + +# ReSharper inspection severities +resharper_arrange_redundant_parentheses_highlighting = hint +resharper_arrange_this_qualifier_highlighting = hint +resharper_arrange_type_member_modifiers_highlighting = hint +resharper_arrange_type_modifiers_highlighting = hint +resharper_built_in_type_reference_style_for_member_access_highlighting = hint +resharper_built_in_type_reference_style_highlighting = hint +resharper_redundant_base_qualifier_highlighting = warning +resharper_suggest_var_or_type_built_in_types_highlighting = hint +resharper_suggest_var_or_type_elsewhere_highlighting = hint +resharper_suggest_var_or_type_simple_types_highlighting = hint +resharper_web_config_module_not_resolved_highlighting = warning +resharper_web_config_type_not_resolved_highlighting = warning +resharper_web_config_wrong_module_highlighting = warning + +[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config}] +indent_style = space +indent_size = 2 + +[{*.bash,*.sh,*.zsh}] +indent_style = space +indent_size = 2 + +[*.proto] +indent_style = space +indent_size = 2 + +[*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,cc,cginc,compute,cp,cpp,cppm,cs,cshtml,cu,cuh,cxx,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,nuspec,paml,razor,resw,resx,shader,skin,tpp,usf,ush,vb,xaml,xamlx,xoml,xsd}] +indent_style = space +indent_size = 4 +tab_width = 4 diff --git a/Maple2.Model/Error/PartyError.cs b/Maple2.Model/Error/PartyError.cs new file mode 100644 index 00000000..d8e8a5d4 --- /dev/null +++ b/Maple2.Model/Error/PartyError.cs @@ -0,0 +1,96 @@ + +// ReSharper disable InconsistentNaming, IdentifierTypo + +using System.ComponentModel; + +namespace Maple2.Model.Error; + +public enum PartyError : byte { + none = 0, + [Description("The party is full.")] + s_party_err_full = 2, + [Description("You are not the party leader.")] + s_party_err_not_chief = 4, + [Description("The party has already been made.")] + s_party_err_already = 5, + [Description("You cannot invite that player to the party.")] + s_party_err_not_exist = 7, + [Description("{0} declined the party invitation.")] + s_party_err_deny = 9, + [Description("You cannot invite yourself to a party.")] + s_party_err_myself = 11, + [Description("{0} failed to respond to your party invitation.")] + s_party_err_deny_by_timeout = 12, + [Description("That player cannot accept party invites at this time.")] + s_party_err_cannot_invite = 14, + [Description("{0} has already received a party request.")] + s_party_err_alreadyInvite = 15, + [Description("You did not meet the entry requirements.")] + s_party_err_fail_enterable_result = 16, + [Description("Your Level is lower than the minimum level requirement.")] + s_party_err_lack_level = 17, + [Description("Your Gear Score is lower than the minimum Gear Score requirement.")] + s_party_err_lack_gear_score = 18, + [Description("The party is full.")] + s_party_err_full_limit_player = 19, + [Description("{0} refused the party invitation.")] + s_party_err_deny_by_auto = 20, + [Description("{0} cannot accept party invitations right now. Please try again later.")] + s_party_err_deny_by_system = 21, + [Description("This recruitment listing has been deleted.")] + s_party_err_invalid_party = 22, + [Description("Recruitment listing outdated. Please refresh and try again.")] + s_party_err_invalid_chief = 23, + [Description("This recruitment listing has been deleted.")] + s_party_err_invalid_recruit = 24, + [Description("Recruitment listing outdated. Please refresh and try again.")] + s_party_err_wrong_party = 25, + [Description("Recruitment listing outdated. Please refresh and try again.")] + s_party_err_wrong_recruit = 26, + [Description("Not enough merets.")] + s_err_lack_merat = 27, + [Description("You already received a party invite.")] + s_party_err_inviteMe = 28, + [Description("You cannot reset the dungeon while a party member is still inside.")] + s_room_party_err_is_in_user = 29, + [Description("You cannot send a party invite while fighting a dungeon boss.")] + s_party_invite_boss_room = 30, + [Description("Party not found.")] + s_party_err_not_found = 31, + [Description("You requested to join {0}'s party.")] + s_party_request_invite = 32, + [Description("Another request is already in progress.")] + s_party_err_already_vote = 33, + [Description("Not enough party members to start a kick vote.")] + s_party_err_vote_need_more_people = 34, + [Description("Please wait before requesting another vote.")] + s_party_err_vote_cooldown = 35, + [Description("That can only be done while battling the boss of {0}.")] + s_party_err_vote_cannot_kick_vote = 36, + [Description("That can only be done while fighting in dungeons.")] + s_party_err_vote_cannot_kick_vote_only_dungeon = 37, + [Description("You cannot kick a party member while you are fighting a dungeon boss.")] + s_party_expel_boss_room = 38, + [Description("You cannot kick a player that is already in a Mushking Royale match.")] + s_party_expel_maple_survival_squad = 39, + [Description("Only the party leader can send the request.")] + s_dungeonMatch_error_isNotChief = 40, + [Description("A party member has disconnected.")] + s_dungeonMatch_error_hasOfflineUser = 41, + [Description("A party member is still in the dungeon.\nPlease try again after all party members have exited the dungeon.")] + s_dungeonMatch_error_insideDungeonUser = 42, + [Description("You're already matching for other content.")] + s_party_err_dungeon_match_another = 43, + [Description("Only the party leader can send the request.")] + s_party_err_isNotChief = 44, + [Description("A party member is offline.")] + s_party_err_hasOfflineUser = 45, + [Description("A party member is already playing Mushking Royale.")] + s_party_err_inside_survival = 46, + [Description("You're already matching for other content.")] + s_party_err_another_matching = 47, + [Description("A party member is queueing for solo Mushking Royale.")] + s_party_err_survival_has_solo_register = 48, + [Description("A Mushking Royale squad cannot have more than 4 players.")] + s_maple_survival_error_squad_register_over_count = 49, +} diff --git a/Maple2.Model/Game/Party/Party.cs b/Maple2.Model/Game/Party/Party.cs new file mode 100644 index 00000000..43640bcf --- /dev/null +++ b/Maple2.Model/Game/Party/Party.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Maple2.Model.Metadata; +using Maple2.PacketLib.Tools; +using Maple2.Tools; +using Maple2.Tools.Extensions; + +namespace Maple2.Model.Game; + +public class Party : IByteSerializable { + private int capacity = Constant.PartyMaxCapacity; + public int Capacity { + get => capacity; + set { + capacity = Math.Clamp(value, Constant.PartyMinCapacity, Constant.PartyMaxCapacity); + } + } + + public required int Id { get; init; } + public required long LeaderAccountId; + public required long LeaderCharacterId; + public required string LeaderName; + public long CreationTime; + public int DungeonId = 0; + public string MatchPartyName = ""; + public int MatchPartyId = 0; + public bool IsMatching = false; + public bool RequireApproval = false; + public readonly ConcurrentDictionary Members; + + [SetsRequiredMembers] + public Party(int id, long leaderAccountId, long leaderCharacterId, string leaderName) { + Id = id; + LeaderAccountId = leaderAccountId; + LeaderCharacterId = leaderCharacterId; + LeaderName = leaderName; + CreationTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + Members = new ConcurrentDictionary(); + } + + [SetsRequiredMembers] + public Party(int id, PartyMember leader) : this(id, leader.AccountId, leader.CharacterId, leader.Name) { } + + public void WriteTo(IByteWriter writer) { + writer.WriteBool(true); // joining from offline? + writer.WriteInt(Id); + writer.WriteLong(LeaderCharacterId); + + byte memberCount = (byte) Members.Count; + writer.WriteByte(memberCount); + foreach (PartyMember member in Members.Values) { + writer.WriteBool(!member.Info.Online); + writer.WriteClass(member); + member.WriteDungeonEligibility(writer); + } + + writer.WriteBool(false); // unk bool + writer.WriteInt(DungeonId); + writer.WriteBool(false); // unk bool + } +} diff --git a/Maple2.Model/Game/Party/PartyInvite.cs b/Maple2.Model/Game/Party/PartyInvite.cs new file mode 100644 index 00000000..e2b967ed --- /dev/null +++ b/Maple2.Model/Game/Party/PartyInvite.cs @@ -0,0 +1,9 @@ +namespace Maple2.Model.Game; + +public class PartyInvite { + public enum Response : byte { + Accept = 1, + RejectInvite = 9, + RejectTimeout = 12, + } +} diff --git a/Maple2.Model/Game/Party/PartyMember.cs b/Maple2.Model/Game/Party/PartyMember.cs new file mode 100644 index 00000000..8a523900 --- /dev/null +++ b/Maple2.Model/Game/Party/PartyMember.cs @@ -0,0 +1,91 @@ +using System; +using System.Numerics; +using System.Threading; +using Maple2.Model.Common; +using Maple2.Model.Enum; +using Maple2.PacketLib.Tools; +using Maple2.Tools; +using Maple2.Tools.Extensions; + +namespace Maple2.Model.Game; + +public class PartyMember : IByteSerializable, IDisposable { + public long PartyId { get; init; } + public required PlayerInfo Info; + public long JoinTime; + public long LoginTime; + public long AccountId => Info.AccountId; + public long CharacterId => Info.CharacterId; + public string Name => Info.Name; + public byte ReadyState = 0; + + public CancellationTokenSource? TokenSource; + + public void WriteTo(IByteWriter writer) { + writer.WriteLong(Info.AccountId); + writer.WriteLong(Info.CharacterId); + writer.WriteUnicodeString(Info.Name); + writer.Write(Info.Gender); + writer.WriteByte(1); + writer.WriteLong(); + writer.WriteInt(); + writer.WriteInt(Info.MapId); + writer.WriteInt(Info.MapId); + writer.WriteInt(Info.PlotMapId); + writer.WriteShort(Info.Level); + writer.WriteShort(Info.Channel); + writer.WriteInt((int) Info.Job.Code()); + writer.Write(Info.Job); + writer.WriteInt((int) Info.CurrentHp); + writer.WriteInt((int) Info.TotalHp); + writer.WriteShort(); + writer.WriteLong(); + writer.WriteLong(); + writer.WriteLong(); + writer.WriteInt(); + writer.Write(default); + writer.WriteInt(Info.GearScore); + writer.Write(default); + writer.WriteLong(); + writer.Write(default); + writer.WriteLong(); + writer.WriteUnicodeString(); + writer.WriteUnicodeString(Info.Motto); + writer.WriteUnicodeString(Info.Picture); + writer.WriteByte(); + writer.WriteByte(); + writer.WriteClass(new Mastery()); + writer.WriteUnicodeString(); + writer.WriteLong(); + writer.WriteLong(); + writer.WriteLong(); + writer.WriteInt(); + writer.WriteByte(); + writer.WriteBool(false); + writer.WriteLong(); + writer.WriteInt(); + writer.WriteInt(); + writer.WriteLong(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + writer.WriteInt(); + writer.WriteLong(); + writer.WriteInt(); + writer.WriteInt(); + writer.WriteShort(); + writer.WriteLong(); + } + + public void WriteDungeonEligibility(IByteWriter writer) { + var Count = 0; + writer.WriteInt(Count); + for (var i = 0; i < Count; i++) { + writer.WriteInt(); + writer.WriteByte(); + } + } + + public void Dispose() { + TokenSource?.Cancel(); + TokenSource?.Dispose(); + TokenSource = null; + } +} diff --git a/Maple2.Model/Game/User/Character.cs b/Maple2.Model/Game/User/Character.cs index 7ba62b65..e6481446 100644 --- a/Maple2.Model/Game/User/Character.cs +++ b/Maple2.Model/Game/User/Character.cs @@ -44,6 +44,7 @@ public class Character { public string Motto = string.Empty; public string GuildName = string.Empty; public long GuildId; + public int PartyId; public required Mastery Mastery; public AchievementInfo AchievementInfo; } diff --git a/Maple2.Model/Metadata/Constants.cs b/Maple2.Model/Metadata/Constants.cs index 46c24b34..077b02f4 100644 --- a/Maple2.Model/Metadata/Constants.cs +++ b/Maple2.Model/Metadata/Constants.cs @@ -72,6 +72,8 @@ public static class Constant { public const ItemTag BeautySkinVoucherTag = ItemTag.beauty_skin; public const ItemTag BeautyItemColorVoucherTag = ItemTag.beauty_itemcolor; public const int HairPaletteId = 2; + public const int PartyMaxCapacity = 10; + public const int PartyMinCapacity = 4; public const long FurnishingBaseId = 2870000000000000000; diff --git a/Maple2.Server.Core/Sync/PlayerInfoUpdateEvent.cs b/Maple2.Server.Core/Sync/PlayerInfoUpdateEvent.cs index 8d78f9b8..471993ab 100644 --- a/Maple2.Server.Core/Sync/PlayerInfoUpdateEvent.cs +++ b/Maple2.Server.Core/Sync/PlayerInfoUpdateEvent.cs @@ -20,6 +20,7 @@ public enum UpdateField { // Presets Buddy = Profile | Job | Level | Map | Channel | Home | Trophy, Guild = Profile | Job | Level | GearScore | Map | Channel | Home | Trophy, + Party = Profile | Job | Level | GearScore | Health | Map | Channel | Home, All = int.MaxValue, } diff --git a/Maple2.Server.Core/proto/channel/channel.proto b/Maple2.Server.Core/proto/channel/channel.proto index 89b60be8..77b565b5 100644 --- a/Maple2.Server.Core/proto/channel/channel.proto +++ b/Maple2.Server.Core/proto/channel/channel.proto @@ -15,6 +15,8 @@ service Channel { rpc UpdatePlayer(maple2.PlayerUpdateRequest) returns (maple2.PlayerUpdateResponse); // Manage guild. rpc Guild(GuildRequest) returns (GuildResponse); + // Manage party. + rpc Party(PartyRequest) returns (PartyResponse); // Notify character about new mail. rpc MailNotification(maple2.MailNotificationRequest) returns (maple2.MailNotificationResponse); @@ -62,3 +64,44 @@ message GuildRequest { message GuildResponse { int32 error = 1; } + +message PartyRequest { + message Invite { + int64 sender_id = 1; + string sender_name = 2; + } + message InviteReply { + string name = 1; + int32 reply = 2; + } + message AddMember { + int64 character_id = 1; + int64 join_time = 2; + int64 login_time = 3; + } + message RemoveMember { + int64 character_id = 1; + bool is_kicked = 2; + } + message UpdateLeader { + int64 character_id = 1; + } + message Disband { + int64 character_id = 1; + } + + int32 party_id = 1; + repeated int64 receiver_ids = 2; + oneof Party { + Invite invite = 3; + InviteReply invite_reply = 4; + AddMember add_member = 5; + RemoveMember remove_member = 6; + UpdateLeader update_leader = 7; + Disband disband = 8; + } +} + +message PartyResponse { + int32 error = 1; +} diff --git a/Maple2.Server.Core/proto/common.proto b/Maple2.Server.Core/proto/common.proto index 404322bf..a7c045da 100644 --- a/Maple2.Server.Core/proto/common.proto +++ b/Maple2.Server.Core/proto/common.proto @@ -8,7 +8,7 @@ message ChatRequest { string recipient_name = 2; } message Party { - int64 party_id = 1; + int32 party_id = 1; } message Guild { int64 guild_id = 1; diff --git a/Maple2.Server.Core/proto/world/world.proto b/Maple2.Server.Core/proto/world/world.proto index 60b7aef4..3b91d20e 100644 --- a/Maple2.Server.Core/proto/world/world.proto +++ b/Maple2.Server.Core/proto/world/world.proto @@ -21,6 +21,9 @@ service World { // Manage guild. rpc GuildInfo(GuildInfoRequest) returns (GuildInfoResponse); rpc Guild(GuildRequest) returns (GuildResponse); + // Manage party. + rpc PartyInfo(PartyInfoRequest) returns (PartyInfoResponse); + rpc Party(PartyRequest) returns (PartyResponse); // Retrieve player info from online player. rpc PlayerInfo(maple2.PlayerInfoRequest) returns (maple2.PlayerInfoResponse); // Update player info @@ -173,3 +176,79 @@ message GuildResponse { int32 error = 3; } + +message PartyInfo { + message Member { + int64 character_id = 1; + int64 join_time = 2; + int64 login_time = 3; + } + + int32 id = 1; + int64 leader_account_id = 2; + int64 leader_character_id = 3; + string leader_name = 4; + int64 creation_time = 5; + optional int32 dungeon_id = 6; + optional string match_party_name = 7; + optional int32 match_party_id = 8; + optional bool is_matching = 9; + optional bool require_approval = 10; + repeated Member members = 11; +} + +message PartyInfoRequest { + optional int32 party_id = 1; + optional int64 character_id = 2; +} + +message PartyInfoResponse { + optional PartyInfo party = 1; +} + +message PartyRequest { + message Create {} + + message Disband { + int32 party_id = 1; + } + message Invite { + int32 party_id = 1; + int64 receiver_id = 2; + } + message RespondInvite { + int32 party_id = 1; + int32 reply = 2; + } + message Leave { + int32 party_id = 1; + } + message Kick { + int32 party_id = 1; + int64 receiver_id = 2; + } + message UpdateLeader { + int32 party_id = 1; + optional int64 character_id = 2; + } + + int64 requestor_id = 1; + oneof party { + Create create = 2; + Disband disband = 3; + Invite invite = 4; + RespondInvite respond_invite = 5; + Leave leave = 6; + Kick kick = 7; + UpdateLeader update_leader = 8; + } +} + +message PartyResponse { + oneof Info { + int32 party_id = 1; + PartyInfo party = 2; + } + + int32 error = 3; +} diff --git a/Maple2.Server.Game/Manager/PartyManager.cs b/Maple2.Server.Game/Manager/PartyManager.cs new file mode 100644 index 00000000..31376fab --- /dev/null +++ b/Maple2.Server.Game/Manager/PartyManager.cs @@ -0,0 +1,240 @@ +using System; +using System.Linq; +using System.Threading; +using Maple2.Model.Error; +using Maple2.Model.Game; +using Maple2.Server.Core.Sync; +using Maple2.Server.Game.Packets; +using Maple2.Server.Game.Session; +using Maple2.Server.Game.Util.Sync; +using Maple2.Server.World.Service; +using Maple2.Tools.Extensions; +using Serilog; +using WorldClient = Maple2.Server.World.Service.World.WorldClient; + +namespace Maple2.Server.Game.Manager; + +public class PartyManager : IDisposable { + private readonly GameSession session; + private readonly WorldClient world; + + public Party? Party { get; private set; } + public int Id => Party?.Id ?? 0; + + private readonly CancellationTokenSource tokenSource; + + private readonly ILogger logger = Log.Logger.ForContext(); + + public PartyManager(WorldClient world, GameSession session) { + this.session = session; + this.world = world; + tokenSource = new CancellationTokenSource(); + + PartyInfoResponse response = session.World.PartyInfo(new PartyInfoRequest { + CharacterId = session.CharacterId, + }); + if (response.Party != null) { + SetParty(response.Party); + } + } + + public void Dispose() { + session.Dispose(); + tokenSource.Dispose(); + + if (Party != null) { + foreach (PartyMember member in Party.Members.Values) { + member.Dispose(); + } + } + } + + public void Load() { + if (Party == null) { + return; + } + + session.Send(PartyPacket.Load(Party)); + } + + public bool SetParty(PartyInfo info) { + if (Party != null) { + return false; + } + + PartyMember[] members = info.Members.Select(member => { + if (!session.PlayerInfo.GetOrFetch(member.CharacterId, out PlayerInfo? playerInfo)) { + return null; + } + + var result = new PartyMember { + PartyId = info.Id, + Info = playerInfo.Clone(), + JoinTime = member.JoinTime, + LoginTime = member.LoginTime, + }; + return result; + }).WhereNotNull().ToArray(); + + PartyMember? leader = members.SingleOrDefault(member => member.CharacterId == info.LeaderCharacterId); + if (leader == null) { + logger.Error("Party {PartyId} does not have a valid leader", info.Id); + session.Send(PartyPacket.Error(PartyError.s_party_err_not_found)); + return false; + } + + var party = new Party(info.Id, leader) { + CreationTime = info.CreationTime, + DungeonId = info.DungeonId, + MatchPartyName = info.MatchPartyName, + MatchPartyId = info.MatchPartyId, + IsMatching = info.IsMatching, + RequireApproval = info.RequireApproval, + }; + foreach (PartyMember member in members) { + if (party.Members.TryAdd(member.CharacterId, member)) { + BeginListen(member); + } + } + + Party = party; + + session.Player.Value.Character.PartyId = Party.Id; + session.Send(PartyPacket.Load(Party)); + return true; + } + + public void RemoveParty() { + if (Party == null) { + return; + } + + Party = null; + session.Player.Value.Character.PartyId = 0; + } + + public bool AddMember(PartyMember member) { + if (Party == null) { + return false; + } + if (!Party.Members.TryAdd(member.CharacterId, member)) { + return false; + } + + BeginListen(member); + session.Send(PartyPacket.Joined(member)); + return true; + } + + public bool RemoveMember(long characterId, bool isKick, bool isSelf) { + if (Party == null) { + return false; + } + if (!Party.Members.TryRemove(characterId, out PartyMember? member)) { + return false; + } + EndListen(member); + + session.Send(isKick ? PartyPacket.Kicked(member.CharacterId) : PartyPacket.Leave(member.CharacterId, isSelf)); + return true; + } + + public bool UpdateLeader(long newLeaderCharacterId) { + if (Party == null) { + return false; + } + + PartyMember? leader = Party.Members.Values.SingleOrDefault(member => member.CharacterId == newLeaderCharacterId); + if (leader == null) { + logger.Error("Party {PartyId} does not have a valid leader", Party.Id); + session.Send(PartyPacket.Error(PartyError.s_party_err_not_found)); + return false; + } + + Party.LeaderCharacterId = leader.CharacterId; + Party.LeaderAccountId = leader.Info.AccountId; + Party.LeaderName = leader.Info.Name; + + session.Send(PartyPacket.NotifyUpdateLeader(newLeaderCharacterId)); + return true; + } + + public bool Disband() { + if (Party == null) { + return false; + } + + foreach (PartyMember member in Party.Members.Values) { + EndListen(member); + } + + session.Send(PartyPacket.Disband()); + RemoveParty(); + return true; + } + + public PartyMember? GetMember(string name) { + return Party?.Members.Values.FirstOrDefault(member => member.Name == name); + } + + public PartyMember? GetMember(long characterId) { + if (Party?.Members.TryGetValue(characterId, out PartyMember? member) == true) { + return member; + } + + return null; + } + + #region PlayerInfo Events + private void BeginListen(PartyMember member) { + // Clean up previous token if necessary + if (member.TokenSource != null) { + logger.Warning("BeginListen called on Member {Id} that was already listening", member.CharacterId); + EndListen(member); + } + + member.TokenSource = CancellationTokenSource.CreateLinkedTokenSource(tokenSource.Token); + CancellationToken token = member.TokenSource.Token; + var listener = new PlayerInfoListener(UpdateField.Party, (type, info) => SyncUpdate(token, member.CharacterId, type, info)); + session.PlayerInfo.Listen(member.Info.CharacterId, listener); + } + + private void EndListen(PartyMember member) { + member.TokenSource?.Cancel(); + member.TokenSource?.Dispose(); + member.TokenSource = null; + } + + private bool SyncUpdate(CancellationToken cancel, long id, UpdateField type, IPlayerInfo info) { + if (cancel.IsCancellationRequested || Party == null || !Party.Members.TryGetValue(id, out PartyMember? member)) { + return true; + } + + bool wasOnline = member.Info.Online; + member.Info.Update(type, info); + member.LoginTime = info.UpdateTime; + + if (type == UpdateField.Health || type == UpdateField.Level) { + session.Send(PartyPacket.UpdateStats(member)); + } else { + session.Send(PartyPacket.Update(member)); + } + + if (member.Info.Online != wasOnline) { + session.Send(member.Info.Online + ? PartyPacket.NotifyLogin(member) + : PartyPacket.NotifyLogout(member.CharacterId)); + + if (!member.Info.Online && member.CharacterId == Party.LeaderCharacterId) { + world.Party(new PartyRequest { + RequestorId = member.CharacterId, + UpdateLeader = new PartyRequest.Types.UpdateLeader { + PartyId = session.Party.Id, + }, + }); + } + } + return false; + } + #endregion +} diff --git a/Maple2.Server.Game/PacketHandlers/PartyHandler.cs b/Maple2.Server.Game/PacketHandlers/PartyHandler.cs new file mode 100644 index 00000000..d8f47d9c --- /dev/null +++ b/Maple2.Server.Game/PacketHandlers/PartyHandler.cs @@ -0,0 +1,257 @@ +using System; +using Grpc.Core; +using Maple2.Database.Storage; +using Maple2.Model.Enum; +using Maple2.Model.Error; +using Maple2.Model.Game; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.PacketHandlers; +using Maple2.Server.Game.Packets; +using Maple2.Server.Game.Session; +using Maple2.Server.World.Service; +using WorldClient = Maple2.Server.World.Service.World.WorldClient; + +namespace Maple2.Server.Game.PacketHandlers; + +public class PartyHandler : PacketHandler { + public override RecvOp OpCode => RecvOp.Party; + + private enum Command : byte { + Invite = 1, + InviteResponse = 2, + Leave = 3, + Kick = 4, + SetLeader = 17, + MatchPartyJoin = 23, + SummonParty = 29, + Unknown = 32, + PartySearch = 33, + CancelPartySearch = 34, + VoteKick = 45, + ReadyCheck = 46, + ReadyCheckResponse = 48 + } + + #region Autofac Autowired + // ReSharper disable MemberCanBePrivate.Global + public required WorldClient World { private get; init; } + // ReSharper restore All + #endregion + + public override void Handle(GameSession session, IByteReader packet) { + var command = packet.Read(); + switch (command) { + case Command.Invite: + HandleInvite(session, packet); + return; + case Command.InviteResponse: + HandleInviteResponse(session, packet); + return; + case Command.Leave: + HandleLeave(session, packet); + return; + case Command.Kick: + HandleKick(session, packet); + return; + case Command.SetLeader: + HandleSetLeader(session, packet); + return; + case Command.MatchPartyJoin: + HandleMatchPartyJoin(session, packet); + return; + case Command.SummonParty: + HandleSummonParty(session, packet); + return; + case Command.Unknown: + HandleUnknown(session, packet); + return; + case Command.PartySearch: + HandlePartySearch(session, packet); + return; + case Command.CancelPartySearch: + HandleCancelPartySearch(session, packet); + return; + case Command.VoteKick: + HandleVoteKick(session, packet); + return; + case Command.ReadyCheck: + HandleReadyCheck(session, packet); + return; + case Command.ReadyCheckResponse: + HandleReadyCheckResponse(session, packet); + return; + } + + } + + private void HandleInvite(GameSession session, IByteReader packet) { + if (session.Party.Party == null) { + // Create new party + PartyResponse response = World.Party(new PartyRequest { + RequestorId = session.CharacterId, + Create = new PartyRequest.Types.Create { }, + }); + + var error = (PartyError) response.Error; + if (error != PartyError.none) { + session.Send(PartyPacket.Error(error)); + return; + } + + session.Party.SetParty(response.Party); + } else if (session.Party.Party.LeaderCharacterId != session.CharacterId) { + session.Send(PartyPacket.Error(PartyError.s_party_err_not_chief)); + return; + } + + string playerName = packet.ReadUnicodeString(); + using GameStorage.Request db = session.GameStorage.Context(); + long characterId = db.GetCharacterId(playerName); + if (characterId == 0) { + session.Send(PartyPacket.Error(PartyError.s_party_err_cannot_invite)); + return; + } + + try { + var request = new PartyRequest { + RequestorId = session.CharacterId, + Invite = new PartyRequest.Types.Invite { + PartyId = session.Party.Id, + ReceiverId = characterId, + }, + }; + PartyResponse response = World.Party(request); + var error = (PartyError) response.Error; + if (error != PartyError.none) { + session.Send(PartyPacket.Error(error, playerName)); + return; + } + } catch (RpcException ex) { + Logger.Error(ex, "Failed to invite {Name} to party", playerName); + session.Send(PartyPacket.Error(PartyError.s_party_err_not_found)); + } + } + + private void HandleInviteResponse(GameSession session, IByteReader packet) { + string name = packet.ReadUnicodeString(); + PartyInvite.Response response = (PartyInvite.Response) packet.ReadByte(); + int partyId = packet.ReadInt(); + + if (session.Party.Party != null) { + session.Send(PartyPacket.Error(PartyError.s_party_err_already)); + return; + } + + PartyResponse partyResponse = World.Party(new PartyRequest { + RequestorId = session.CharacterId, + RespondInvite = new PartyRequest.Types.RespondInvite { + PartyId = partyId, + Reply = (int) response, + }, + }); + var error = (PartyError) partyResponse.Error; + if (error != PartyError.none) { + session.Send(PartyPacket.Error(error)); + return; + } + + if (response == PartyInvite.Response.Accept) { + session.Party.SetParty(partyResponse.Party); + } + } + + private void HandleLeave(GameSession session, IByteReader packet) { + if (session.Party.Party == null) { + session.Send(PartyPacket.Error(PartyError.s_party_err_not_found)); + return; + } + + PartyResponse response = World.Party(new PartyRequest { + RequestorId = session.CharacterId, + Leave = new PartyRequest.Types.Leave { + PartyId = session.Party.Id, + }, + }); + } + + private void HandleKick(GameSession session, IByteReader packet) { + long targetCharacterId = packet.ReadLong(); + PartyResponse response = World.Party(new PartyRequest { + RequestorId = session.CharacterId, + Kick = new PartyRequest.Types.Kick { + PartyId = session.Party.Id, + ReceiverId = targetCharacterId, + }, + }); + + var error = (PartyError) response.Error; + if (error != PartyError.none) { + return; + } + } + + private void HandleSetLeader(GameSession session, IByteReader packet) { + string targetName = packet.ReadUnicodeString(); + using GameStorage.Request db = session.GameStorage.Context(); + long targetCharacterId = db.GetCharacterId(targetName); + if (targetCharacterId == 0) { + session.Send(PartyPacket.Error(PartyError.s_party_err_not_exist)); + return; + } + + PartyResponse response = World.Party(new PartyRequest { + RequestorId = session.CharacterId, + UpdateLeader = new PartyRequest.Types.UpdateLeader { + PartyId = session.Party.Id, + CharacterId = targetCharacterId, + }, + }); + } + + private void HandleMatchPartyJoin(GameSession session, IByteReader packet) { + // TODO: Implement + int partyId = packet.ReadInt(); + string leaderName = packet.ReadUnicodeString(); + long unk1 = packet.ReadLong(); + } + + private void HandleSummonParty(GameSession session, IByteReader packet) { + // TODO: Implement + } + + private void HandleUnknown(GameSession session, IByteReader packet) { + // TODO: Implement + byte unk1 = packet.ReadByte(); + } + + private void HandlePartySearch(GameSession session, IByteReader packet) { + // TODO: Implement + int dungeonId = packet.ReadInt(); + } + + private void HandleCancelPartySearch(GameSession session, IByteReader packet) { + // TODO: Implement + byte unk1 = packet.ReadByte(); + if (unk1 != 3) { + int const_1 = packet.ReadInt(); + int unk2 = packet.ReadInt(); + byte unk3 = packet.ReadByte(); + } + } + + private void HandleVoteKick(GameSession session, IByteReader packet) { + // TODO: Implement + long targetCharacterId = packet.ReadLong(); + } + + private void HandleReadyCheck(GameSession session, IByteReader packet) { + // TODO: Implement + } + + private void HandleReadyCheckResponse(GameSession session, IByteReader packet) { + // TODO: Implement + int voteId = packet.ReadInt(); + bool isReady = packet.ReadBool(); + } +} diff --git a/Maple2.Server.Game/PacketHandlers/UserChatHandler.cs b/Maple2.Server.Game/PacketHandlers/UserChatHandler.cs index 657497b1..ef8b1cbf 100644 --- a/Maple2.Server.Game/PacketHandlers/UserChatHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/UserChatHandler.cs @@ -117,7 +117,9 @@ private void HandleParty(GameSession session, string message) { CharacterId = session.CharacterId, Name = session.PlayerName, Message = message, - Party = new ChatRequest.Types.Party {PartyId = 0}, + Party = new ChatRequest.Types.Party { + PartyId = session.Party.Id, + }, }; try { diff --git a/Maple2.Server.Game/Packets/PartyPacket.cs b/Maple2.Server.Game/Packets/PartyPacket.cs new file mode 100644 index 00000000..06a2d48b --- /dev/null +++ b/Maple2.Server.Game/Packets/PartyPacket.cs @@ -0,0 +1,355 @@ +using Maple2.Model.Error; +using Maple2.Model.Game; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.Packets; +using Maple2.Tools.Extensions; +using System; + +namespace Maple2.Server.Game.Packets; + +public static class PartyPacket { + private enum Command : byte { + Error = 0, + Joined = 2, + Leave = 3, + Kicked = 4, + NotifyLogin = 5, + NotifyLogout = 6, + Disbanded = 7, + NotifyUpdateLeader = 8, + Load = 9, + Invited = 11, + UpdateMember = 12, + //13 - duplicate of 12 + UpdateDungeonInfo = 14, + Unknown1 = 15, + Tombstone = 18, + UpdateStats = 19, + DungeonNotice = 20, + Unknown2 = 21, + DungeonReset = 25, + PartyFinder = 26, + PartySearch = 30, + PartySearchDungeon = 31, + DungeonRecord = 35, + Unknown3 = 37, + DungeonHelperCooldown = 40, + JoinRequest = 44, + StartVote = 47, + ReadyCheck = 48, + EndReadyCheck = 49, + SurvivalPartySearch = 54, + } + + public static ByteWriter Error(PartyError error, string targetName = "") { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Error); + pWriter.Write(error); + pWriter.WriteUnicodeString(targetName); + + return pWriter; + } + + public static ByteWriter Joined(PartyMember member) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Joined); + pWriter.WriteClass(member); + member.WriteDungeonEligibility(pWriter); + + return pWriter; + } + + public static ByteWriter Leave(long targetCharacterId, bool isSelf) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Leave); + pWriter.WriteLong(targetCharacterId); + pWriter.WriteBool(isSelf); + + return pWriter; + } + + public static ByteWriter Kicked(long targetCharacterId) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Kicked); + pWriter.WriteLong(targetCharacterId); + + return pWriter; + } + + public static ByteWriter NotifyLogin(PartyMember member) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.NotifyLogin); + pWriter.WriteClass(member); + + return pWriter; + } + + public static ByteWriter NotifyLogout(long targetCharacterId) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.NotifyLogout); + pWriter.WriteLong(targetCharacterId); + + return pWriter; + } + + public static ByteWriter Disband() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Disbanded); + + return pWriter; + } + + public static ByteWriter NotifyUpdateLeader(long newLeaderCharacterId) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.NotifyUpdateLeader); + pWriter.WriteLong(newLeaderCharacterId); + + return pWriter; + } + + public static ByteWriter Load(Party party) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Load); + pWriter.WriteClass(party); + pWriter.WriteMatchParty(party); + + return pWriter; + } + + public static ByteWriter Invite(int partyId, string name) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Invited); + + pWriter.WriteUnicodeString(name); + pWriter.WriteInt(partyId); + + return pWriter; + } + + public static ByteWriter Update(PartyMember member) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.UpdateMember); + + pWriter.WriteLong(member.CharacterId); + pWriter.WriteClass(member); + + return pWriter; + } + + public static ByteWriter UpdateDungeonInfo(PartyMember member) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.UpdateDungeonInfo); + + pWriter.Write(member.CharacterId); + member.WriteDungeonEligibility(pWriter); + + return pWriter; + } + + public static ByteWriter Unknown1() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Unknown1); + + pWriter.WriteLong(); + pWriter.WriteInt(); + + return pWriter; + } + + public static ByteWriter Tombstone() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Tombstone); + + pWriter.WriteLong(); + pWriter.WriteBool(true); // Dark tombstone vs light tombstone + + return pWriter; + } + + public static ByteWriter UpdateStats(PartyMember member) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.UpdateStats); + + pWriter.WriteLong(member.CharacterId); + pWriter.WriteLong(member.AccountId); + pWriter.WriteInt((int) member.Info.CurrentHp); + pWriter.WriteInt((int) member.Info.TotalHp); + pWriter.WriteShort(member.Info.Level); + + return pWriter; + } + + public static ByteWriter DungeonNotice() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.DungeonNotice); + + pWriter.WriteUnicodeString(); // Notices: s_party_vote_expired|s_field_enteracne_party_notify_reset_dungeon|s_party_vote_rejected_kick_user + pWriter.WriteUnicodeString("Field_Enterance_Reset_Dungeon"); + pWriter.WriteUnicodeString(); + + return pWriter; + } + + public static ByteWriter Unknown2(string message, bool htmlEncoded = false) { + var text = new InterfaceText(message, htmlEncoded); + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Unknown2); + pWriter.WriteClass(text); + pWriter.WriteUnicodeString(); // effect? + + return pWriter; + } + + public static ByteWriter DungeonReset() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.DungeonReset); + pWriter.WriteBool(false); // started dungeon + pWriter.WriteInt(); // dungeon id + + return pWriter; + } + + public static ByteWriter PartyFinderListing(Party party) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.PartyFinder); + pWriter.WriteMatchParty(party); + + return pWriter; + } + + public static ByteWriter PartySearch(byte type, bool searching) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.PartySearch); + /** + if type == 1: + dungeon_message(1) # s_enum_dungeon_group_normal + elif type == 2: + dungeon_message(8) # s_enum_dungeon_group_worldBoss + elif type == 3: + dungeon_message(11) # s_enum_dungeon_group_event + elif type == 4: + dungeon_message(5) # s_enum_dungeon_group_lapenta + else: + dungeon_message(0) + **/ + pWriter.WriteByte(type); // Type + pWriter.WriteBool(searching); // Searching + pWriter.WriteBool(true); // always true? + pWriter.WriteByte(); + + return pWriter; + } + + public static ByteWriter PartySearchDungeon() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.PartySearchDungeon); + pWriter.WriteLong(); // s_party_match_dungeon + + return pWriter; + } + + public static ByteWriter DungeonRecord() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.DungeonRecord); + pWriter.WriteLong(); + + return pWriter; + } + + public static ByteWriter Unknown3() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.Unknown3); + pWriter.WriteLong(); + pWriter.WriteUnicodeString(); + + return pWriter; + } + + public static ByteWriter DungeonHelperCooldown() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.DungeonHelperCooldown); + pWriter.WriteInt(); // s_party_find_dungeon_helper_cooldown + + return pWriter; + } + + public static ByteWriter JoinRequest(string name) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.JoinRequest); + pWriter.WriteUnicodeString(name); + + return pWriter; + } + + public static ByteWriter StartVote(bool kick, int size) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.StartVote); + pWriter.WriteBool(kick); // 0 = Ready Check, 1 = Kick + pWriter.WriteInt(kick ? 36 : 34); + pWriter.WriteLong(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + // Party Size + pWriter.WriteInt(size); + for (var i = 0; i < size; i++) { + pWriter.WriteLong(); // character id + } + + // Ready Count + pWriter.WriteInt(size); + for (var i = 0; i < size; i++) { + pWriter.WriteLong(); // character id + } + + // Not Ready Count + pWriter.WriteInt(size); + for (var i = 0; i < size; i++) { + pWriter.WriteLong(); // character id + } + + return pWriter; + } + + public static ByteWriter ReadyCheck(long characterId, bool isReady) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.ReadyCheck); + pWriter.WriteLong(characterId); + pWriter.WriteBool(isReady); + + return pWriter; + } + + public static ByteWriter EndReadyCheck() { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.EndReadyCheck); + + return pWriter; + } + + public static ByteWriter SurvivalPartySearch(bool searching) { + var pWriter = Packet.Of(SendOp.Party); + pWriter.Write(Command.SurvivalPartySearch); + pWriter.WriteBool(searching); // Searching + pWriter.WriteBool(true); // always true + + return pWriter; + } + + private static void WriteMatchParty(this IByteWriter writer, Party party) { + writer.WriteBool(party.IsMatching); + if (party.IsMatching) { + writer.WriteLong(party.MatchPartyId); + writer.WriteInt(party.Id); + writer.WriteInt(); // Unknown + writer.WriteInt(); // Unknown + writer.WriteUnicodeString(party.MatchPartyName); + writer.WriteBool(party.RequireApproval); + writer.WriteInt(party.Members.Count); + writer.WriteInt(party.Capacity); + writer.WriteLong(party.LeaderAccountId); + writer.WriteLong(party.LeaderCharacterId); + writer.WriteUnicodeString(party.LeaderName); + writer.WriteLong(party.CreationTime); + } + } +} diff --git a/Maple2.Server.Game/Service/ChannelService.Party.cs b/Maple2.Server.Game/Service/ChannelService.Party.cs new file mode 100644 index 00000000..700c2a20 --- /dev/null +++ b/Maple2.Server.Game/Service/ChannelService.Party.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Grpc.Core; +using Maple2.Model.Error; +using Maple2.Model.Game; +using Maple2.Server.Channel.Service; +using Maple2.Server.Game.Packets; +using Maple2.Server.Game.Session; + +namespace Maple2.Server.Game.Service; + +public partial class ChannelService { + public override Task Party(PartyRequest request, ServerCallContext context) { + switch (request.PartyCase) { + case PartyRequest.PartyOneofCase.Invite: + return Task.FromResult(PartyInvite(request.PartyId, request.ReceiverIds, request.Invite)); + case PartyRequest.PartyOneofCase.InviteReply: + return Task.FromResult(PartyInviteReply(request.ReceiverIds, request.InviteReply)); + case PartyRequest.PartyOneofCase.AddMember: + return Task.FromResult(AddPartyMember(request.PartyId, request.ReceiverIds, request.AddMember)); + case PartyRequest.PartyOneofCase.RemoveMember: + return Task.FromResult(RemovePartyMember(request.ReceiverIds, request.RemoveMember)); + case PartyRequest.PartyOneofCase.UpdateLeader: + return Task.FromResult(UpdatePartyLeader(request.ReceiverIds, request.UpdateLeader)); + case PartyRequest.PartyOneofCase.Disband: + return Task.FromResult(Disband(request.ReceiverIds, request.Disband)); + default: + return Task.FromResult(new PartyResponse { Error = (int) PartyError.s_party_err_not_found }); + } + } + + private PartyResponse PartyInvite(int partyId, IEnumerable receiverIds, PartyRequest.Types.Invite invite) { + foreach (long receiverId in receiverIds) { + if (!server.GetSession(receiverId, out GameSession? session)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_alreadyInvite }; + } + if (session.Buddy.IsBlocked(invite.SenderId)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_cannot_invite }; + } + // Check if the receiver is already in a party, and if it has more than 1 member + if (session.Party.Party != null && session.Party.Party.Members.Count > 1) { + if (session.Party.Party.LeaderCharacterId == receiverId) { + session.Send(PartyPacket.JoinRequest(invite.SenderName)); + return new PartyResponse { Error = (int) PartyError.s_party_request_invite }; + } + return new PartyResponse { Error = (int) PartyError.s_party_err_cannot_invite }; + } + // Remove any existing 1 person party + session.Party.RemoveParty(); + + session.Send(PartyPacket.Invite(partyId, invite.SenderName)); + } + + return new PartyResponse(); + } + + private PartyResponse PartyInviteReply(IEnumerable receiverIds, PartyRequest.Types.InviteReply reply) { + foreach (long characterId in receiverIds) { + if (!server.GetSession(characterId, out GameSession? session)) { + continue; + } + + session.Send(PartyPacket.Error((PartyError) reply.Reply, reply.Name)); + } + + return new PartyResponse(); + } + + private PartyResponse AddPartyMember(long partyId, IEnumerable receiverIds, PartyRequest.Types.AddMember add) { + if (!playerInfos.GetOrFetch(add.CharacterId, out PlayerInfo? info)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_cannot_invite }; + } + + foreach (long characterId in receiverIds) { + if (!server.GetSession(characterId, out GameSession? session)) { + continue; + } + + session.Party.AddMember(new PartyMember { + PartyId = partyId, + Info = info.Clone(), + JoinTime = add.JoinTime, + LoginTime = add.LoginTime, + }); + } + + return new PartyResponse(); + } + + private PartyResponse RemovePartyMember(IEnumerable receiverIds, PartyRequest.Types.RemoveMember remove) { + foreach (long characterId in receiverIds) { + if (!server.GetSession(characterId, out GameSession? session)) { + continue; + } + + bool isSelf = characterId == remove.CharacterId; + session.Party.RemoveMember(remove.CharacterId, remove.IsKicked, isSelf); + if (isSelf) { + session.Party.RemoveParty(); + } + } + + return new PartyResponse(); + } + + private PartyResponse UpdatePartyLeader(IEnumerable receiverIds, PartyRequest.Types.UpdateLeader update) { + foreach (long characterId in receiverIds) { + if (!server.GetSession(characterId, out GameSession? session)) { + continue; + } + + session.Party.UpdateLeader(update.CharacterId); + } + + return new PartyResponse(); + } + + private PartyResponse Disband(IEnumerable receiverIds, object disband) { + foreach (long characterId in receiverIds) { + if (!server.GetSession(characterId, out GameSession? session)) { + continue; + } + + session.Party.Disband(); + } + + return new PartyResponse(); + } +} + diff --git a/Maple2.Server.Game/Session/GameSession.cs b/Maple2.Server.Game/Session/GameSession.cs index 1a7b1910..e45cfd69 100644 --- a/Maple2.Server.Game/Session/GameSession.cs +++ b/Maple2.Server.Game/Session/GameSession.cs @@ -84,6 +84,7 @@ public sealed partial class GameSession : Core.Network.Session { public QuestManager Quest { get; set; } public FieldManager? Field { get; set; } public FieldPlayer Player { get; private set; } + public PartyManager Party { get; set; } public GameSession(TcpClient tcpClient, GameServer server, IComponentContext context) : base(tcpClient) { this.server = server; @@ -135,6 +136,7 @@ public bool EnterServer(long accountId, long characterId, Guid machineId) { Config = new ConfigManager(db, this); Buddy = new BuddyManager(db, this); Item = new ItemManager(db, this, ItemStatsCalc); + Party = new PartyManager(World, this); if (!PrepareField(player.Character.MapId)) { Send(MigrationPacket.MoveResult(MigrationError.s_move_err_default)); @@ -147,6 +149,10 @@ public bool EnterServer(long accountId, long characterId, Guid machineId) { CharacterId = characterId, }; playerUpdate.SetFields(UpdateField.All, player); + playerUpdate.Health = new HealthInfo { + CurrentHp = Player.Stats[BasicAttribute.Health].Current, + TotalHp = Player.Stats[BasicAttribute.Health].Total, + }; PlayerInfo.SendUpdate(playerUpdate); //session.Send(Packet.Of(SendOp.REQUEST_SYSTEM_INFO)); @@ -159,6 +165,7 @@ public bool EnterServer(long accountId, long characterId, Guid machineId) { Guild.Load(); // Club Buddy.Load(); + Party.Load(); Send(TimeSyncPacket.Reset(DateTimeOffset.UtcNow)); Send(TimeSyncPacket.Set(DateTimeOffset.UtcNow)); @@ -277,8 +284,8 @@ public bool EnterField() { } //if (!Player.Value.Unlock.Maps.Contains(Player.Value.Character.MapId)) { - // Figure out what maps give exp. MapType >= 1 < 5 || 11 ? - //Exp.AddExp(ExpType.mapCommon); + // Figure out what maps give exp. MapType >= 1 < 5 || 11 ? + //Exp.AddExp(ExpType.mapCommon); //} Player.Value.Unlock.Maps.Add(Player.Value.Character.MapId); Config.LoadHotBars(); @@ -421,6 +428,7 @@ protected override void Dispose(bool disposing) { #endif Guild.Dispose(); Buddy.Dispose(); + Party.Dispose(); using (GameStorage.Request db = GameStorage.Context()) { db.BeginTransaction(); diff --git a/Maple2.Server.World/Containers/PartyLookup.cs b/Maple2.Server.World/Containers/PartyLookup.cs new file mode 100644 index 00000000..eadfee32 --- /dev/null +++ b/Maple2.Server.World/Containers/PartyLookup.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Maple2.Model.Error; +using Maple2.Model.Game; + +namespace Maple2.Server.World.Containers; + +public class PartyLookup : IDisposable { + private readonly ChannelClientLookup channelClients; + private readonly PlayerInfoLookup playerLookup; + + private readonly ConcurrentDictionary parties; + private int nextPartyId = 1; + + public PartyLookup(ChannelClientLookup channelClients, PlayerInfoLookup playerLookup) { + this.channelClients = channelClients; + this.playerLookup = playerLookup; + + parties = new ConcurrentDictionary(); + } + + public void Dispose() { + foreach (PartyManager manager in parties.Values) { + manager.Dispose(); + } + } + + public bool TryGet(int partyId, [NotNullWhen(true)] out PartyManager? party) { + return parties.TryGetValue(partyId, out party); + } + + public bool TryGetByCharacter(long characterId, [NotNullWhen(true)] out PartyManager? party) { + party = null; + foreach (PartyManager manager in parties.Values) { + if (manager.Party.Members.TryGetValue(characterId, out PartyMember? member)) { + party = manager; + return true; + } + } + return false; + } + + public PartyError Create(long leaderId, out int partyId) { + partyId = nextPartyId++; + + PlayerInfo? leaderInfo = playerLookup.GetPlayerInfo(leaderId); + if (leaderInfo == null) { + return PartyError.s_party_err_not_found; + } + + Party party = new Party(partyId, leaderInfo.AccountId, leaderInfo.CharacterId, leaderInfo.Name); + PartyManager manager = new PartyManager(party) { + ChannelClients = channelClients, + }; + + if (!parties.TryAdd(partyId, manager)) { + return PartyError.s_party_err_not_found; + } + + return manager.Join(leaderInfo); + } + + public PartyError Disband(long requestorId, int partyId) { + if (!TryGet(partyId, out PartyManager? manager)) { + return PartyError.s_party_err_not_found; + } + if (requestorId != manager.Party.LeaderCharacterId) { + return PartyError.s_party_err_not_chief; + } + if (!parties.TryRemove(partyId, out manager)) { + // Failed to remove party after validating. + return PartyError.s_party_err_not_found; + } + manager.Dispose(); + + return PartyError.none; + } +} diff --git a/Maple2.Server.World/Containers/PartyManager.cs b/Maple2.Server.World/Containers/PartyManager.cs new file mode 100644 index 00000000..f7633013 --- /dev/null +++ b/Maple2.Server.World/Containers/PartyManager.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Grpc.Core; +using Maple2.Model.Error; +using Maple2.Model.Game; +using Maple2.Server.Channel.Service; +using ChannelClient = Maple2.Server.Channel.Service.Channel.ChannelClient; + +namespace Maple2.Server.World.Containers; + +public class PartyManager : IDisposable { + public required ChannelClientLookup ChannelClients { get; init; } + public readonly Party Party; + private readonly ConcurrentDictionary pendingInvites; + + public PartyManager(Party party) { + Party = party; + pendingInvites = new ConcurrentDictionary(); + } + + public void Dispose() { + Broadcast(new PartyRequest { + Disband = new PartyRequest.Types.Disband { }, + }); + } + + public void Broadcast(PartyRequest request) { + if (request.PartyId > 0 && request.PartyId != Party.Id) { + throw new InvalidOperationException($"Broadcasting {request.PartyCase} for incorrect party: {request.PartyId} => {Party.Id}"); + } + + request.PartyId = Party.Id; + foreach (IGrouping group in Party.Members.Values.GroupBy(member => member.Info.Channel)) { + if (!ChannelClients.TryGetClient(group.Key, out ChannelClient? client)) { + continue; + } + + request.ReceiverIds.Clear(); + request.ReceiverIds.AddRange(group.Select(member => member.Info.CharacterId)); + + try { + client.Party(request); + } catch { } + } + } + + public void FindNewLeader(long characterId) { + PartyMember? newLeader = Party.Members.Values.FirstOrDefault(m => m.CharacterId != characterId); + if (newLeader == null) { + CheckForDisband(); + return; + } + UpdateLeader(characterId, newLeader.CharacterId); + } + + public bool CheckForDisband() { + if (Party.Members.Count <= 2) { + Dispose(); + return true; + } + return false; + } + + public PartyError Invite(long requestorId, PlayerInfo player) { + if (!Party.Members.TryGetValue(requestorId, out PartyMember? requestor)) { + return PartyError.s_party_err_not_exist; + } + bool isLeader = requestorId == Party.LeaderCharacterId; + if (!isLeader) { + return PartyError.s_party_err_not_chief; + } + if (Party.Members.Count >= Party.Capacity) { + return PartyError.s_party_err_full; + } + if (Party.Members.ContainsKey(player.CharacterId)) { + return PartyError.s_party_err_cannot_invite; + } + if (!ChannelClients.TryGetClient(player.Channel, out ChannelClient? client)) { + return PartyError.s_party_err_cannot_invite; + } + + try { + pendingInvites[player.CharacterId] = (requestor.Name, DateTime.Now.AddSeconds(30)); + var request = new PartyRequest { + PartyId = Party.Id, + ReceiverIds = { player.CharacterId }, + Invite = new PartyRequest.Types.Invite { + SenderId = requestor.CharacterId, + SenderName = requestor.Name, + }, + }; + + PartyResponse response = client.Party(request); + return (PartyError) response.Error; + } catch (RpcException) { + return PartyError.s_party_err_not_found; + } + } + + public PartyError Join(PlayerInfo info) { + if (Party.Members.Count >= Party.Capacity) { + return PartyError.s_party_err_full_limit_player; + } + if (Party.Members.ContainsKey(info.CharacterId)) { + return PartyError.s_party_err_alreadyInvite; + } + + PartyMember member = new PartyMember { + PartyId = Party.Id, + Info = info.Clone(), + JoinTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + LoginTime = info.UpdateTime, + }; + + Broadcast(new PartyRequest { + AddMember = new PartyRequest.Types.AddMember { + CharacterId = member.CharacterId, + JoinTime = member.JoinTime, + LoginTime = member.LoginTime, + }, + }); + Party.Members.TryAdd(info.CharacterId, member); + return PartyError.none; + } + + public PartyError Kick(long requestorId, long characterId) { + if (!Party.Members.TryGetValue(requestorId, out PartyMember? _)) { + return PartyError.s_party_err_not_found; + } + if (requestorId != Party.LeaderCharacterId) { + return PartyError.s_party_err_not_chief; + } + if (characterId == Party.LeaderCharacterId) { + return PartyError.none; + } + if (!Party.Members.TryGetValue(characterId, out PartyMember? member)) { + return PartyError.s_party_err_not_exist; + } + if (CheckForDisband()) { + return PartyError.none; + } + + Broadcast(new PartyRequest { + RemoveMember = new PartyRequest.Types.RemoveMember { + CharacterId = member.CharacterId, + IsKicked = true, + }, + }); + Party.Members.TryRemove(member.CharacterId, out _); + return PartyError.none; + } + + public PartyError Leave(long characterId) { + if (!Party.Members.TryGetValue(characterId, out PartyMember? member)) { + return PartyError.s_party_err_not_found; + } + + if (characterId == Party.LeaderCharacterId) { + FindNewLeader(characterId); + } + + if (CheckForDisband()) { + return PartyError.none; + } + + Broadcast(new PartyRequest { + RemoveMember = new PartyRequest.Types.RemoveMember { + CharacterId = member.CharacterId, + }, + }); + Party.Members.TryRemove(member.CharacterId, out _); + return PartyError.none; + } + + public PartyError UpdateLeader(long requestorId, long characterId) { + if (!Party.Members.TryGetValue(requestorId, out PartyMember? requestor)) { + return PartyError.s_party_err_not_found; + } + if (!Party.Members.TryGetValue(characterId, out PartyMember? member)) { + return PartyError.s_party_err_not_found; + } + + if (requestorId != Party.LeaderCharacterId) { + return PartyError.s_party_err_not_chief; + } + + Party.LeaderCharacterId = member.Info.CharacterId; + Party.LeaderAccountId = member.Info.AccountId; + Party.LeaderName = member.Info.Name; + Broadcast(new PartyRequest { + UpdateLeader = new PartyRequest.Types.UpdateLeader { + CharacterId = characterId, + }, + }); + + return PartyError.none; + } + + public string ConsumeInvite(long characterId) { + foreach ((long id, (string name, DateTime expiryTime)) in pendingInvites) { + // Remove any expired entries while iterating. + if (expiryTime < DateTime.Now) { + pendingInvites.Remove(id, out _); + continue; + } + + if (id == characterId) { + pendingInvites.Remove(id, out _); + return name; + } + } + + return string.Empty; + } +} diff --git a/Maple2.Server.World/Program.cs b/Maple2.Server.World/Program.cs index daa11392..b207ac2d 100644 --- a/Maple2.Server.World/Program.cs +++ b/Maple2.Server.World/Program.cs @@ -54,6 +54,8 @@ .SingleInstance(); autofac.RegisterType() .SingleInstance(); + autofac.RegisterType() + .SingleInstance(); }); WebApplication app = builder.Build(); diff --git a/Maple2.Server.World/Service/WorldService.Chat.cs b/Maple2.Server.World/Service/WorldService.Chat.cs index d520b68e..81371bc2 100644 --- a/Maple2.Server.World/Service/WorldService.Chat.cs +++ b/Maple2.Server.World/Service/WorldService.Chat.cs @@ -60,14 +60,14 @@ private Task WhisperChat(ChatRequest request) { return Task.FromResult(new ChatResponse()); } } - + private Task WorldChat(ChatRequest request) { foreach ((int channel, ChannelClient client) in channelClients) { client.Chat(request); } return Task.FromResult(new ChatResponse()); } - + private Task SuperChat(ChatRequest request) { foreach ((int channel, ChannelClient client) in channelClients) { client.Chat(request); diff --git a/Maple2.Server.World/Service/WorldService.Party.cs b/Maple2.Server.World/Service/WorldService.Party.cs new file mode 100644 index 00000000..66cab5ba --- /dev/null +++ b/Maple2.Server.World/Service/WorldService.Party.cs @@ -0,0 +1,174 @@ +using System.Linq; +using System.Threading.Tasks; +using Grpc.Core; +using Maple2.Model.Error; +using Maple2.Model.Game; +using Maple2.Server.World.Containers; +using ChannelPartyRequest = Maple2.Server.Channel.Service.PartyRequest; + +namespace Maple2.Server.World.Service; + +public partial class WorldService { + public override Task PartyInfo(PartyInfoRequest request, ServerCallContext context) { + if (!(request.PartyId != 0 && partyLookup.TryGet(request.PartyId, out PartyManager? manager)) && !(request.CharacterId != 0 && partyLookup.TryGetByCharacter(request.CharacterId, out manager))) { + return Task.FromResult(new PartyInfoResponse()); + } + + return Task.FromResult(new PartyInfoResponse { Party = ToPartyInfo(manager.Party) }); + } + + public override Task Party(PartyRequest request, ServerCallContext context) { + switch (request.PartyCase) { + case PartyRequest.PartyOneofCase.Create: + return Task.FromResult(CreateParty(request.RequestorId, request.Create)); + case PartyRequest.PartyOneofCase.Disband: + return Task.FromResult(DisbandParty(request.RequestorId, request.Disband)); + case PartyRequest.PartyOneofCase.Invite: + return Task.FromResult(InviteParty(request.RequestorId, request.Invite)); + case PartyRequest.PartyOneofCase.RespondInvite: + return Task.FromResult(RespondInviteParty(request.RequestorId, request.RespondInvite)); + case PartyRequest.PartyOneofCase.Leave: + return Task.FromResult(LeaveParty(request.RequestorId, request.Leave)); + case PartyRequest.PartyOneofCase.Kick: + return Task.FromResult(KickParty(request.RequestorId, request.Kick)); + case PartyRequest.PartyOneofCase.UpdateLeader: + return Task.FromResult(UpdateLeader(request.RequestorId, request.UpdateLeader)); + default: + return Task.FromResult(new PartyResponse { Error = (int) PartyError.none }); + } + } + + private PartyResponse CreateParty(long requestorId, PartyRequest.Types.Create create) { + PartyError error = partyLookup.Create(requestorId, out int partyId); + if (error != PartyError.none) { + return new PartyResponse { Error = (int) error }; + } + if (!partyLookup.TryGet(partyId, out PartyManager? manager)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_not_found }; + } + + return new PartyResponse { Party = ToPartyInfo(manager.Party) }; + } + + private PartyResponse DisbandParty(long requestorId, PartyRequest.Types.Disband disband) { + PartyError error = partyLookup.Disband(requestorId, disband.PartyId); + if (error != PartyError.none) { + return new PartyResponse { Error = (int) error }; + } + + return new PartyResponse { PartyId = disband.PartyId }; + } + + private PartyResponse InviteParty(long requestorId, PartyRequest.Types.Invite invite) { + if (!partyLookup.TryGet(invite.PartyId, out PartyManager? manager)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_not_found }; + } + if (!playerLookup.TryGet(invite.ReceiverId, out PlayerInfo? info)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_cannot_invite }; + } + + PartyError error = manager.Invite(requestorId, info); + if (error != PartyError.none) { + return new PartyResponse { Error = (int) error }; + } + + return new PartyResponse { PartyId = invite.PartyId }; + } + + private PartyResponse RespondInviteParty(long characterId, PartyRequest.Types.RespondInvite respond) { + if (!partyLookup.TryGet(respond.PartyId, out PartyManager? manager)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_not_found }; + } + string requestorName = manager.ConsumeInvite(characterId); + if (string.IsNullOrEmpty(requestorName)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_cannot_invite }; + } + if (!playerLookup.TryGet(characterId, out PlayerInfo? info)) { + return new PartyResponse { Error = (int) PartyError.none }; + } + + if (respond.Reply == (int) PartyInvite.Response.Accept) { + PartyError error = manager.Join(info); + if (error != PartyError.none) { + return new PartyResponse { Error = (int) error }; + } + + return new PartyResponse { Party = ToPartyInfo(manager.Party) }; + } + + manager.Broadcast(new ChannelPartyRequest { + InviteReply = new ChannelPartyRequest.Types.InviteReply { + Name = info.Name, + Reply = respond.Reply, + }, + }); + + return new PartyResponse { PartyId = manager.Party.Id }; + } + + private PartyResponse LeaveParty(long requestorId, PartyRequest.Types.Leave leave) { + if (!partyLookup.TryGet(leave.PartyId, out PartyManager? manager)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_not_found }; + } + + PartyError error = manager.Leave(requestorId); + if (error != PartyError.none) { + return new PartyResponse { Error = (int) error }; + } + + return new PartyResponse { PartyId = leave.PartyId }; + } + + private PartyResponse KickParty(long requestorId, PartyRequest.Types.Kick kick) { + if (!partyLookup.TryGet(kick.PartyId, out PartyManager? manager)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_not_found }; + } + + PartyError error = manager.Kick(requestorId, kick.ReceiverId); + if (error != PartyError.none) { + return new PartyResponse { Error = (int) error }; + } + + return new PartyResponse { PartyId = kick.PartyId }; + } + + private PartyResponse UpdateLeader(long requestorId, PartyRequest.Types.UpdateLeader update) { + if (!partyLookup.TryGet(update.PartyId, out PartyManager? manager)) { + return new PartyResponse { Error = (int) PartyError.s_party_err_not_found }; + } + + if (update.CharacterId == 0) { + manager.FindNewLeader(requestorId); + return new PartyResponse { PartyId = update.PartyId }; + } + + PartyError error = manager.UpdateLeader(requestorId, update.CharacterId); + if (error != PartyError.none) { + return new PartyResponse { Error = (int) error }; + } + + return new PartyResponse { PartyId = update.PartyId }; + } + + private static PartyInfo ToPartyInfo(Party party) { + return new PartyInfo { + Id = party.Id, + CreationTime = party.CreationTime, + LeaderAccountId = party.LeaderAccountId, + LeaderCharacterId = party.LeaderCharacterId, + LeaderName = party.LeaderName, + DungeonId = party.DungeonId, + MatchPartyName = party.MatchPartyName, + MatchPartyId = party.MatchPartyId, + IsMatching = party.IsMatching, + RequireApproval = party.RequireApproval, + Members = { + party.Members.Values.Select(member => new PartyInfo.Types.Member { + CharacterId = member.CharacterId, + JoinTime = member.JoinTime, + LoginTime = member.LoginTime, + }), + }, + }; + } +} diff --git a/Maple2.Server.World/Service/WorldService.cs b/Maple2.Server.World/Service/WorldService.cs index 0c68b866..894788ea 100644 --- a/Maple2.Server.World/Service/WorldService.cs +++ b/Maple2.Server.World/Service/WorldService.cs @@ -10,14 +10,15 @@ public partial class WorldService : World.WorldBase { private readonly ChannelClientLookup channelClients; private readonly PlayerInfoLookup playerLookup; private readonly GuildLookup guildLookup; - + private readonly PartyLookup partyLookup; private readonly ILogger logger = Log.Logger.ForContext(); - public WorldService(IMemoryCache tokenCache, ChannelClientLookup channelClients, PlayerInfoLookup playerLookup, GuildLookup guildLookup) { + public WorldService(IMemoryCache tokenCache, ChannelClientLookup channelClients, PlayerInfoLookup playerLookup, GuildLookup guildLookup, PartyLookup partyLookup) { this.tokenCache = tokenCache; this.channelClients = channelClients; this.playerLookup = playerLookup; this.guildLookup = guildLookup; + this.partyLookup = partyLookup; } public override Task Channels(ChannelsRequest request, ServerCallContext context) {