diff --git a/docs/Packets/C1-11-ObjectHitExtended_by-server.md b/docs/Packets/C1-11-ObjectHitExtended_by-server.md index fd9e2109e..8d78388e6 100644 --- a/docs/Packets/C1-11-ObjectHitExtended_by-server.md +++ b/docs/Packets/C1-11-ObjectHitExtended_by-server.md @@ -16,6 +16,8 @@ The damage is shown at the object which received the hit. | 1 | 1 | Byte | 16 | Packet header - length of the packet | | 2 | 1 | Byte | 0x11 | Packet header - packet type identifier | | 3 << 0 | 4 bit | DamageKind | | Kind | +| 3 << 4 | 1 bit | Boolean | | IsRageFighterStreakHit | +| 3 << 5 | 1 bit | Boolean | | IsRageFighterStreakFinalHit | | 3 << 6 | 1 bit | Boolean | | IsDoubleDamage | | 3 << 7 | 1 bit | Boolean | | IsTripleDamage | | 4 | 2 | ShortLittleEndian | | ObjectId | @@ -34,7 +36,7 @@ Defines the kind of the damage. | 1 | IgnoreDefenseCyan | Cyan color, usually used by ignore defense damage. | | 2 | ExcellentLightGreen | Light green color, usually used by excellent damage. | | 3 | CriticalBlue | Blue color, usually used by critical damage. | -| 4 | LightPink | Light pink color. | +| 4 | ReflectedLightPink | Light pink color, usually used by reflected damage. | | 5 | PoisonDarkGreen | Dark green color, usually used by poison damage. | -| 6 | ReflectedDarkPink | Dark pink color, usually used by reflected damage. | +| 6 | DarkPink | Dark pink color. | | 7 | White | White color. | \ No newline at end of file diff --git a/docs/Packets/C1-11-ObjectHit_by-server.md b/docs/Packets/C1-11-ObjectHit_by-server.md index de9120dfc..7dc7c6dfd 100644 --- a/docs/Packets/C1-11-ObjectHit_by-server.md +++ b/docs/Packets/C1-11-ObjectHit_by-server.md @@ -19,6 +19,8 @@ The damage is shown at the object which received the hit. | 3 | 2 | ShortBigEndian | | ObjectId | | 5 | 2 | ShortBigEndian | | HealthDamage | | 7 << 0 | 4 bit | DamageKind | | Kind | +| 7 << 4 | 1 bit | Boolean | | IsRageFighterStreakHit | +| 7 << 5 | 1 bit | Boolean | | IsRageFighterStreakFinalHit | | 7 << 6 | 1 bit | Boolean | | IsDoubleDamage | | 7 << 7 | 1 bit | Boolean | | IsTripleDamage | | 8 | 2 | ShortBigEndian | | ShieldDamage | @@ -33,7 +35,7 @@ Defines the kind of the damage. | 1 | IgnoreDefenseCyan | Cyan color, usually used by ignore defense damage. | | 2 | ExcellentLightGreen | Light green color, usually used by excellent damage. | | 3 | CriticalBlue | Blue color, usually used by critical damage. | -| 4 | LightPink | Light pink color. | +| 4 | ReflectedLightPink | Light pink color, usually used by reflected damage. | | 5 | PoisonDarkGreen | Dark green color, usually used by poison damage. | -| 6 | ReflectedDarkPink | Dark pink color, usually used by reflected damage. | +| 6 | DarkPink | Dark pink color. | | 7 | White | White color. | \ No newline at end of file diff --git a/docs/Packets/C1-D2-05-CashShopStorageListRequest_by-client.md b/docs/Packets/C1-D2-05-CashShopStorageListRequest_by-client.md index 4a842687f..0606520bd 100644 --- a/docs/Packets/C1-D2-05-CashShopStorageListRequest_by-client.md +++ b/docs/Packets/C1-D2-05-CashShopStorageListRequest_by-client.md @@ -13,7 +13,7 @@ In case of opening, the server returns if the cash shop is available. If the pla | Index | Length | Data Type | Value | Description | |-------|--------|-----------|-------|-------------| | 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | -| 1 | 1 | Byte | 8 | Packet header - length of the packet | +| 1 | 1 | Byte | 10 | Packet header - length of the packet | | 2 | 1 | Byte | 0xD2 | Packet header - packet type identifier | | 3 | 1 | Byte | 0x05 | Packet header - sub packet type identifier | | 4 | 4 | IntegerLittleEndian | | PageIndex | diff --git a/docs/Packets/C1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md b/docs/Packets/C1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md index b0f25d456..d75de9f7b 100644 --- a/docs/Packets/C1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md +++ b/docs/Packets/C1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md @@ -13,7 +13,7 @@ The item is applied or added to the inventory. | Index | Length | Data Type | Value | Description | |-------|--------|-----------|-------|-------------| | 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | -| 1 | 1 | Byte | 5 | Packet header - length of the packet | +| 1 | 1 | Byte | 15 | Packet header - length of the packet | | 2 | 1 | Byte | 0xD2 | Packet header - packet type identifier | | 3 | 1 | Byte | 0x0B | Packet header - sub packet type identifier | | 4 | 4 | IntegerLittleEndian | | BaseItemCode | diff --git a/src/DataModel/Configuration/MonsterDefinition.cs b/src/DataModel/Configuration/MonsterDefinition.cs index d511a62cc..fca37dd56 100644 --- a/src/DataModel/Configuration/MonsterDefinition.cs +++ b/src/DataModel/Configuration/MonsterDefinition.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // diff --git a/src/GameLogic/AttackableExtensions.cs b/src/GameLogic/AttackableExtensions.cs index abba95f01..a3a6be6f1 100644 --- a/src/GameLogic/AttackableExtensions.cs +++ b/src/GameLogic/AttackableExtensions.cs @@ -239,7 +239,9 @@ public static async ValueTask CalculateDamageAsync(this IAttacker attac if (soulBarrierManaToll > 0 && defender.Attributes[Stats.CurrentMana] > soulBarrierManaToll) { manaToll = soulBarrierManaToll; - dmg -= (int)(dmg * defender.Attributes[Stats.SoulBarrierReceiveDecrement]); + float soulBarrierReduction = defender.Attributes[Stats.SoulBarrierReceiveDecrement]; + soulBarrierReduction = Math.Clamp(soulBarrierReduction, 0f, 0.9f); + dmg -= (int)(dmg * soulBarrierReduction); } dmg += (int)attacker.Attributes[Stats.FinalDamageBonus]; diff --git a/src/GameLogic/GameContext.cs b/src/GameLogic/GameContext.cs index f0e405356..a4538f33d 100644 --- a/src/GameLogic/GameContext.cs +++ b/src/GameLogic/GameContext.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -80,6 +80,7 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider this.DropGenerator = dropGenerator; this.ConfigurationChangeMediator = changeMediator; this.ItemPowerUpFactory = new ItemPowerUpFactory(loggerFactory.CreateLogger()); + this.PartyManager = new PartyManager(configuration.MaximumPartySize, loggerFactory.CreateLogger()); this._recoverTimer = new Timer(this.RecoverTimerElapsed, null, this.Configuration.RecoveryInterval, this.Configuration.RecoveryInterval); this._tasksTimer = new Timer(this.ExecutePeriodicTasks, null, 1000, 1000); this.FeaturePlugIns = new FeaturePlugInContainer(this.PlugInManager); @@ -148,16 +149,17 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider /// /// Gets the players by character name dictionary. /// - public ConcurrentDictionary PlayersByCharacterName { get; } = new(StringComparer.OrdinalIgnoreCase); + public ConcurrentDictionary PlayersByCharacterName { get; } = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// public DuelRoomManager DuelRoomManager { get; set; } - /// - /// Gets the state of the active self defenses. - /// + /// public ConcurrentDictionary<(Player Attacker, Player Defender), DateTime> SelfDefenseState { get; } = new(); + /// + public IPartyManager PartyManager { get; } + /// public ILoggerFactory LoggerFactory { get; } diff --git a/src/GameLogic/IGameContext.cs b/src/GameLogic/IGameContext.cs index f42b66c5c..e340a1fd0 100644 --- a/src/GameLogic/IGameContext.cs +++ b/src/GameLogic/IGameContext.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -112,6 +112,11 @@ public interface IGameContext /// ConcurrentDictionary<(Player Attacker, Player Defender), DateTime> SelfDefenseState { get; } + /// + /// Gets the party manager which handles party creation and persistence. + /// + IPartyManager PartyManager { get; } + /// /// Gets the initialized maps which are hosted on this context. /// diff --git a/src/GameLogic/IPartyManager.cs b/src/GameLogic/IPartyManager.cs new file mode 100644 index 000000000..64ee67d79 --- /dev/null +++ b/src/GameLogic/IPartyManager.cs @@ -0,0 +1,39 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +/// +/// Manages party creation and tracks party membership for member reconnection. +/// +public interface IPartyManager +{ + /// + /// Creates a new party with the configured maximum party size. + /// + /// The newly created party. + Party CreateParty(); + + /// + /// Called when a party member reconnects. Restores the live player into their previous party, + /// replacing the snapshot that was created on disconnect. + /// + /// The reconnected member. + ValueTask OnMemberReconnectedAsync(IPartyMember member); + + /// + /// Registers that a character belongs to a party. Called by internally + /// when members are added, replaced, or removed. + /// + /// The character name. + /// The party. + internal void TrackMembership(string characterName, Party party); + + /// + /// Removes the party tracking for a character. Called by internally + /// when members leave or are replaced. + /// + /// The character name. + internal void UntrackMembership(string characterName); +} \ No newline at end of file diff --git a/src/GameLogic/IPartyMember.cs b/src/GameLogic/IPartyMember.cs index fd7b36dbb..1a01b7bf9 100644 --- a/src/GameLogic/IPartyMember.cs +++ b/src/GameLogic/IPartyMember.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -33,4 +33,9 @@ public interface IPartyMember : IWorldObserver, IObservable, IIdentifiable, ILoc /// Gets the name. /// string Name { get; } + + /// + /// Gets a value indicating whether the member is currently connected to the game. + /// + bool IsConnected { get; } } \ No newline at end of file diff --git a/src/GameLogic/MUnique.OpenMU.GameLogic.csproj b/src/GameLogic/MUnique.OpenMU.GameLogic.csproj index 0d05fb058..c7048e1ae 100644 --- a/src/GameLogic/MUnique.OpenMU.GameLogic.csproj +++ b/src/GameLogic/MUnique.OpenMU.GameLogic.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/src/GameLogic/MapInitializer.cs b/src/GameLogic/MapInitializer.cs index f3e8a6f16..105df8aa3 100644 --- a/src/GameLogic/MapInitializer.cs +++ b/src/GameLogic/MapInitializer.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // diff --git a/src/GameLogic/NPC/BasicMonsterIntelligence.cs b/src/GameLogic/NPC/BasicMonsterIntelligence.cs index ec5001894..99e2123d1 100644 --- a/src/GameLogic/NPC/BasicMonsterIntelligence.cs +++ b/src/GameLogic/NPC/BasicMonsterIntelligence.cs @@ -28,9 +28,6 @@ public class BasicMonsterIntelligence : INpcIntelligence, IDisposable /// /// Gets or sets a value indicating whether this instance can walk on safezone. /// - /// - /// true if this instance can walk on safezone; otherwise, false. - /// public bool CanWalkOnSafezone { get; protected set; } /// @@ -91,6 +88,13 @@ public virtual bool CanWalkOn(Point target) return this.Monster.CurrentMap.Terrain.AIgrid[target.X, target.Y] == 1; } + /// + /// Determines whether the specified player is being targeted by this monster. + /// + /// The player to check. + /// True if the player is the current target; otherwise, false. + public bool IsTargetingPlayer(Player player) => this.CurrentTarget == player; + /// /// Called when the intelligence starts. /// @@ -129,30 +133,28 @@ protected virtual void Dispose(bool managed) var possibleTargets = tempObservers.OfType() .Where(a => a.IsActive() && !a.IsAtSafezone() && a is not Player { IsInvisible: true }) .ToList(); + + // Also consider summoned monsters belonging to players in range. var summons = possibleTargets.OfType() .Select(p => p.Summon?.Item1) .Where(s => s is not null) .Cast() .WhereActive() .ToList(); - possibleTargets.AddRange(summons); - var closestTarget = possibleTargets.MinBy(a => a.GetDistanceTo(this.Npc)); - return closestTarget; + possibleTargets.AddRange(summons); // todo: check the walk distance + return possibleTargets.MinBy(a => a.GetDistanceTo(this.Npc)); } /// - /// Determines whether this instance can attack. + /// Determines whether this instance can attack this tick. /// - /// - /// true if this instance can attack; otherwise, false. - /// protected virtual ValueTask CanAttackAsync() => ValueTask.FromResult(true); /// - /// Handles the tick without having a target. + /// Handles the tick when no target is available, moves the monster randomly. /// protected virtual async ValueTask TickWithoutTargetAsync() { @@ -161,7 +163,6 @@ protected virtual async ValueTask TickWithoutTargetAsync() return; } - // we move around randomly, so the monster does not look dead when watched from distance. if (await this.IsObservedByAttackerAsync().ConfigureAwait(false)) { await this.Monster.RandomMoveAsync().ConfigureAwait(false); @@ -169,11 +170,8 @@ protected virtual async ValueTask TickWithoutTargetAsync() } /// - /// Determines whether the handled monster is observed by an attacker. + /// Determines whether the handled monster is observed by any attacker. /// - /// - /// true if the handled monster is observed by an attacker; otherwise, false. - /// protected async ValueTask IsObservedByAttackerAsync() { using var readerLock = await this.Monster.ObserverLock.ReaderLockAsync(); @@ -197,7 +195,7 @@ private async void SafeTick() } catch (OperationCanceledException) { - // can be ignored. + // expected during shutdown. } catch (Exception ex) { @@ -228,36 +226,19 @@ private async ValueTask TickAsync() return; } - var target = this.CurrentTarget; - if (target != null) - { - // Old Target out of Range? - if (!target.IsAlive - || target is Player { IsInvisible: true } - || target.IsTeleporting - || target.IsAtSafezone() - || !target.IsInRange(this.Monster.Position, this.Npc.Definition.ViewRange) - || (target is IWorldObserver && !await this.IsTargetInObserversAsync(target).ConfigureAwait(false))) - { - target = this.CurrentTarget = await this.SearchNextTargetAsync().ConfigureAwait(false); - } - } - else - { - target = this.CurrentTarget = await this.SearchNextTargetAsync().ConfigureAwait(false); - } + this.CurrentTarget = await this.ResolveTargetAsync().ConfigureAwait(false); - // no target? - if (target is null) + if (this.CurrentTarget is null) { await this.TickWithoutTargetAsync().ConfigureAwait(false); return; } - // Target in Attack Range? - if (target.IsInRange(this.Monster.Position, this.Monster.Definition.AttackRange) && !this.Monster.IsAtSafezone()) + // Target in attack range — attack. + if (this.CurrentTarget.IsInRange(this.Monster.Position, this.Monster.Definition.AttackRange) + && !this.Monster.IsAtSafezone()) { - await this.Monster.AttackAsync(target).ConfigureAwait(false); // yes, attack + await this.Monster.AttackAsync(this.CurrentTarget).ConfigureAwait(false); return; } @@ -266,18 +247,51 @@ private async ValueTask TickAsync() return; } - // Target in View Range? - if (target.IsInRange(this.Monster.Position, this.Monster.Definition.ViewRange + 1)) + // Target visible but outside attack range, walk toward it. + if (this.CurrentTarget.IsInRange(this.Monster.Position, this.Monster.Definition.ViewRange + 1)) { - // no, walk to the target - var walkTarget = this.Monster.CurrentMap!.Terrain.GetRandomCoordinate(target.Position, this.Monster.Definition.AttackRange); + var walkTarget = this.Monster.CurrentMap!.Terrain.GetRandomCoordinate(this.CurrentTarget.Position, this.Monster.Definition.AttackRange); if (await this.Monster.WalkToAsync(walkTarget).ConfigureAwait(false)) { return; } } - // we move around randomly, so the monster does not look dead when watched from distance. + // Nothing else to do, wander randomly. await this.Monster.RandomMoveAsync().ConfigureAwait(false); } + + /// + /// Returns the current target if still valid, otherwise searches for a new one. + /// + private async ValueTask ResolveTargetAsync() + { + if (this.CurrentTarget is not null && this.IsCurrentTargetValid()) + { + // Double-check the target is still within the observer list (needed for + // players who have moved out of view range server-side). + if (!await this.IsTargetInObserversAsync(this.CurrentTarget).ConfigureAwait(false) + && this.CurrentTarget is IWorldObserver) + { + return await this.SearchNextTargetAsync().ConfigureAwait(false); + } + + return this.CurrentTarget; + } + + return await this.SearchNextTargetAsync().ConfigureAwait(false); + } + + /// + /// Returns true if the current target is still a valid attack candidate. + /// + private bool IsCurrentTargetValid() + { + return this.CurrentTarget is not null + && this.CurrentTarget.IsAlive + && this.CurrentTarget is not Player { IsInvisible: true } + && !this.CurrentTarget.IsTeleporting + && !this.CurrentTarget.IsAtSafezone() + && this.CurrentTarget.IsInRange(this.Monster.Position, this.Npc.Definition.ViewRange); + } } \ No newline at end of file diff --git a/src/GameLogic/NPC/Monster.cs b/src/GameLogic/NPC/Monster.cs index 29f33c9d2..dcb736278 100644 --- a/src/GameLogic/NPC/Monster.cs +++ b/src/GameLogic/NPC/Monster.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -23,7 +23,7 @@ public sealed class Monster : AttackableNpcBase, IAttackable, IAttacker, ISuppor private readonly Walker _walker; /// - /// The power up element of the monsters skill. + /// The power up element of the monster skill. /// It is a "cached" element which will be created on demand and can be applied multiple times. /// private readonly IElement? _skillPowerUp; @@ -96,6 +96,21 @@ public Monster(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, /// protected override bool CanSpawnInSafezone => base.CanSpawnInSafezone || this.SummonedBy is not null; + /// + /// Determines whether the specified player is being targeted by this monster. + /// + /// The player to check. + /// True if the player is the current target; otherwise, false. + public bool IsTargetingPlayer(Player player) + { + if (this._intelligence is BasicMonsterIntelligence basicIntelligence) + { + return basicIntelligence.IsTargetingPlayer(player); + } + + return false; + } + /// /// Attacks the specified target. /// diff --git a/src/GameLogic/OfflineLeveling/BuffHandler.cs b/src/GameLogic/OfflineLeveling/BuffHandler.cs index 8e07e490f..f9e975837 100644 --- a/src/GameLogic/OfflineLeveling/BuffHandler.cs +++ b/src/GameLogic/OfflineLeveling/BuffHandler.cs @@ -6,8 +6,9 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.PlayerActions.Skills; +using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views.World; -using MUnique.OpenMU.Interfaces; /// /// Handles buff application and management for the offline leveling player. @@ -16,6 +17,8 @@ public sealed class BuffHandler { private const int BuffSlotCount = 3; + private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new(); + private readonly OfflineLevelingPlayer _player; private readonly IMuHelperSettings? _config; @@ -34,6 +37,22 @@ public BuffHandler(OfflineLevelingPlayer player, IMuHelperSettings? config) this._config = config; } + /// + /// Gets the configured buff skill IDs from the settings. + /// + public IList ConfiguredBuffIds + { + get + { + if (this._config is null) + { + return []; + } + + return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id]; + } + } + /// /// Checks and applies buffs if configured and needed. /// @@ -45,7 +64,7 @@ public async ValueTask PerformBuffsAsync() return true; } - var buffIds = this.GetConfiguredBuffIds(); + var buffIds = this.ConfiguredBuffIds; if (buffIds.Count == 0) { return true; @@ -53,6 +72,7 @@ public async ValueTask PerformBuffsAsync() this.UpdatePeriodicBuffTimer(); + this._buffSkillIndex = 0; for (int i = 0; i < BuffSlotCount; i++) { int buffId = buffIds[this._buffSkillIndex]; @@ -69,33 +89,31 @@ public async ValueTask PerformBuffsAsync() continue; } - // Try to buff self - if (await this.TryBuffTargetAsync(this._player, skillEntry, false).ConfigureAwait(false)) + if (await this.PerformSelfBuffAsync(skillEntry).ConfigureAwait(false)) { return false; } - // Try to buff party members - if (await this.TryBuffPartyMembersAsync(skillEntry).ConfigureAwait(false)) + if (await this.PerformPartyBuffAsync(skillEntry).ConfigureAwait(false)) { return false; } - // Move to next slot if no one needed this buff this.MoveNextSlot(); } return true; } - private List GetConfiguredBuffIds() + private static bool IsSkillQualifiedForTarget(SkillEntry skillEntry) { - if (this._config is null) + if (skillEntry.Skill is not { } skill) { - return []; + return false; } - return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id]; + return skill.Target != SkillTarget.ImplicitPlayer + && skill.TargetRestriction != SkillTargetRestriction.Self; } private void UpdatePeriodicBuffTimer() @@ -114,29 +132,6 @@ private void UpdatePeriodicBuffTimer() } } - private async ValueTask TryBuffPartyMembersAsync(SkillEntry skillEntry) - { - if (this._config is not { SupportParty: true } || this._player.Party is not { } party) - { - return false; - } - - foreach (var member in party.PartyList.OfType()) - { - if (member == (IAttackable)this._player) - { - continue; - } - - if (await this.TryBuffTargetAsync(member, skillEntry, true).ConfigureAwait(false)) - { - return true; - } - } - - return false; - } - private void MoveNextSlot() { this._buffSkillIndex = (this._buffSkillIndex + 1) % BuffSlotCount; @@ -146,38 +141,93 @@ private void MoveNextSlot() } } - private async ValueTask TryBuffTargetAsync(IAttackable target, SkillEntry skillEntry, bool isPartyMember) + private bool ShouldApplyBuff(IAttackable target, SkillEntry skillEntry, bool isPartyMember) { if (skillEntry.Skill?.MagicEffectDef is not { } effectDef) { return false; } - if (!target.IsActive() || !this._player.IsInRange(target, 8)) + if (!target.IsActive() || !this._player.IsInRange(target, this._config!.HuntingRange)) { return false; } - bool alreadyActive = target.MagicEffectList.ActiveEffects.Values + var alreadyActive = target.MagicEffectList.ActiveEffects.Values + .ToList() .Any(e => e.Definition == effectDef); - bool shouldApply; if (isPartyMember) { - shouldApply = this._config!.BuffDurationForParty ? !alreadyActive : this._buffTimerTriggered; + return this._config!.BuffDurationForParty ? !alreadyActive : this._buffTimerTriggered; } - else + + return !alreadyActive || this._buffTimerTriggered; + } + + private async ValueTask PerformSelfBuffAsync(SkillEntry skillEntry) + { + if (!this.ShouldApplyBuff(this._player, skillEntry, false)) { - shouldApply = !alreadyActive || this._buffTimerTriggered; + return false; } - if (shouldApply) + if (skillEntry.Skill?.Target == SkillTarget.ImplicitParty) { - await target.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); + return await this.PerformImplicitPartyBuffAsync(skillEntry).ConfigureAwait(false); + } + + await this._player.ForEachWorldObserverAsync( + p => p.ShowSkillAnimationAsync(this._player, this._player, skillEntry.Skill!, true), + includeThis: true).ConfigureAwait(false); + await this._player.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); + this.MoveNextSlot(); + return true; + } + + private async ValueTask PerformImplicitPartyBuffAsync(SkillEntry skillEntry) + { + var strategy = this._player.GameContext.PlugInManager + .GetStrategy(skillEntry.Skill!.Number) + ?? DefaultPlugin; + + await strategy.PerformSkillAsync(this._player, this._player, (ushort)skillEntry.Skill.Number).ConfigureAwait(false); + this.MoveNextSlot(); + return true; + } + + private async ValueTask PerformPartyBuffAsync(SkillEntry skillEntry) + { + if (this._config is not { SupportParty: true } || this._player.Party is not { } party) + { + return false; + } + + foreach (var member in party.PartyList.OfType()) + { + if (member == this._player) + { + continue; + } + + if (!IsSkillQualifiedForTarget(skillEntry)) + { + continue; + } + + if (!this.ShouldApplyBuff(member, skillEntry, true)) + { + continue; + } + + await (member as IObservable ?? this._player).ForEachWorldObserverAsync( + p => p.ShowSkillAnimationAsync(this._player, member, skillEntry.Skill!, true), + includeThis: true).ConfigureAwait(false); + await member.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); this.MoveNextSlot(); return true; } return false; } -} +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/CombatHandler.cs b/src/GameLogic/OfflineLeveling/CombatHandler.cs index 0fc610c32..798127e06 100644 --- a/src/GameLogic/OfflineLeveling/CombatHandler.cs +++ b/src/GameLogic/OfflineLeveling/CombatHandler.cs @@ -4,17 +4,12 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; -using MUnique.OpenMU.DataModel.Configuration; -using MUnique.OpenMU.DataModel.Configuration.Items; -using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.NPC; -using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions; using MUnique.OpenMU.GameLogic.PlayerActions.Skills; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views.World; -using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Pathfinding; /// @@ -25,6 +20,9 @@ public sealed class CombatHandler private const byte DefaultRange = 1; private const int ComboFinisherDelayTicks = 3; private const int InterSkillDelayTicks = 1; + private const int MinComboSkillCount = 3; + + private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new(); private const short DrainLifeBaseSkillId = 214; private const short DrainLifeStrengthenerSkillId = 458; @@ -33,7 +31,9 @@ public sealed class CombatHandler private readonly OfflineLevelingPlayer _player; private readonly IMuHelperSettings? _config; private readonly MovementHandler _movementHandler; + private readonly BuffHandler _buffHandler; private readonly Point _originPosition; + private readonly ConditionalSkillSlot[] _conditionalSkillSlots; private IAttackable? _currentTarget; private int _nearbyMonsterCount; @@ -46,13 +46,20 @@ public sealed class CombatHandler /// The offline leveling player. /// The MU helper settings. /// The movement handler. + /// The buff handler. /// The original position to hunt around. - public CombatHandler(OfflineLevelingPlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition) + public CombatHandler(OfflineLevelingPlayer player, IMuHelperSettings? config, MovementHandler movementHandler, BuffHandler buffHandler, Point originPosition) { this._player = player; this._config = config; this._movementHandler = movementHandler; + this._buffHandler = buffHandler; this._originPosition = originPosition; + this._conditionalSkillSlots = config is null ? [] : + [ + new ConditionalSkillSlot(config.ActivationSkill1Id, config.Skill1UseTimer, config.DelayMinSkill1, config.Skill1UseCondition, config.Skill1ConditionAttacking, config.Skill1SubCondition), + new ConditionalSkillSlot(config.ActivationSkill2Id, config.Skill2UseTimer, config.DelayMinSkill2, config.Skill2UseCondition, config.Skill2ConditionAttacking, config.Skill2SubCondition), + ]; } /// @@ -91,6 +98,36 @@ public void DecrementCooldown() } } + /// + /// Performs combat attacks on targets. + /// + /// A value task representing the asynchronous operation. + public async ValueTask PerformAttackAsync() + { + this.RefreshTarget(); + + if (this._currentTarget is null) + { + return; + } + + byte attackRange = this.GetEffectiveAttackRange(); + if (!this.IsTargetInAttackRange(this._currentTarget, attackRange)) + { + await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false); + return; + } + + if (this._config?.UseCombo == true) + { + await this.ExecuteComboAttackAsync().ConfigureAwait(false); + } + else + { + await this.ExecuteAttackAsync(this._currentTarget).ConfigureAwait(false); + } + } + /// /// Performs health recovery through Drain Life attacks if configured. /// @@ -123,44 +160,23 @@ public async ValueTask PerformDrainLifeRecoveryAsync() } } - /// - /// Performs combat attacks on targets. - /// - /// A value task representing the asynchronous operation. - public async ValueTask PerformAttackAsync() + private async ValueTask ExecuteAttackAsync(IAttackable target) { - this.RefreshTarget(); - - if (this._currentTarget is null) + var skill = this.SelectAttackSkill(); + if (skill == null) { - return; - } - - byte attackRange = this.GetEffectiveAttackRange(); - if (!this.IsTargetInAttackRange(this._currentTarget, attackRange)) - { - await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false); - return; + // Do not attack if there are buffs configured to handle buff-only classes. + var buffs = this._buffHandler.ConfiguredBuffIds; + if (buffs.Any(id => id > 0)) + { + return; + } } - if (this._config?.UseCombo == true) - { - await this.ExecuteComboAttackAsync().ConfigureAwait(false); - } - else - { - var skill = this.SelectAttackSkill(); - await this.ExecuteAttackAsync(this._currentTarget, skill, false).ConfigureAwait(false); - } + await this.ExecuteAttackAsync(target, skill, false).ConfigureAwait(false); } - /// - /// Executes an attack on the specified target. - /// - /// The target. - /// The skill entry. - /// If set to true, it's a combo attack. - public async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo) + private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo) { this._player.Rotation = this._player.GetDirectionTo(target); @@ -187,8 +203,28 @@ private void RefreshTarget() this._currentTarget = null; } - this._currentTarget ??= this.FindNearestMonster(); - this._nearbyMonsterCount = this.CountMonstersNearby(); + if (this._currentTarget is null) + { + var monsters = this.GetAttackableMonstersInHuntingRange().ToList(); + this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player)); + this._nearbyMonsterCount = monsters.Count; + } + else + { + this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count(); + } + } + + private IEnumerable GetAttackableMonstersInHuntingRange() + { + if (this._player.CurrentMap is not { } map) + { + return []; + } + + return map.GetAttackablesInRange(this._originPosition, this.HuntingRange) + .OfType() + .Where(this.IsMonsterAttackable); } private bool IsTargetInAttackRange(IAttackable target, byte range) @@ -250,92 +286,77 @@ await this._player.ForEachWorldObserverAsync( private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skill skill) { - var strategy = this._player.GameContext.PlugInManager.GetStrategy((short)skill.Number) - ?? new TargetedSkillDefaultPlugin(); + var strategy = this._player.GameContext.PlugInManager.GetStrategy(skill.Number) + ?? DefaultPlugin; await strategy.PerformSkillAsync(this._player, target, (ushort)skill.Number).ConfigureAwait(false); } - private IAttackable? FindNearestMonster() - { - if (this._player.CurrentMap is not { } map) - { - return null; - } - - return map.GetAttackablesInRange(this._originPosition, this.HuntingRange) - .OfType() - .Where(this.IsMonsterAttackable) - .MinBy(m => m.GetDistanceTo(this._player)); - } - - private int CountMonstersNearby() - { - if (this._player.CurrentMap is not { } map) - { - return 0; - } - - return map.GetAttackablesInRange(this._originPosition, this.HuntingRange) - .OfType() - .Count(this.IsMonsterAttackable); - } - private SkillEntry? SelectAttackSkill() { if (this._config is null) { - return this.GetAnyOffensiveSkill(); + return null; } - var s1 = this.EvaluateConditionalSkill( - this._config.ActivationSkill1Id, - this._config.Skill1UseTimer, - this._config.DelayMinSkill1, - this._config.Skill1UseCondition, - this._config.Skill1SubCondition); - if (s1 is not null) + // If no skills are configured at all, don't attack. + if (this._config.BasicSkillId == 0 + && this._config.ActivationSkill1Id == 0 + && this._config.ActivationSkill2Id == 0) { - return s1; + return null; } - var s2 = this.EvaluateConditionalSkill( - this._config.ActivationSkill2Id, - this._config.Skill2UseTimer, - this._config.DelayMinSkill2, - this._config.Skill2UseCondition, - this._config.Skill2SubCondition); - if (s2 is not null) + foreach (var slot in this._conditionalSkillSlots) { - return s2; + var skill = this.EvaluateConditionalSkill(slot); + if (skill is not null && this.HasEnoughResources(skill)) + { + return skill; + } } if (this._config.BasicSkillId > 0) { - return this._player.SkillList?.GetSkill((ushort)this._config.BasicSkillId); + var basicSkill = this._player.SkillList?.GetSkill((ushort)this._config.BasicSkillId); + if (basicSkill is not null && this.HasEnoughResources(basicSkill)) + { + return basicSkill; + } } - return this.GetAnyOffensiveSkill(); + return null; } - private SkillEntry? EvaluateConditionalSkill(int skillId, bool useTimer, int timerInterval, bool useCond, int subCond) + /// + /// Evaluates whether the skill in the given slot should fire this tick. + /// + private SkillEntry? EvaluateConditionalSkill(ConditionalSkillSlot slot) { - if (skillId <= 0) + if (slot.SkillId <= 0) { return null; } - if (useTimer && timerInterval > 0) + if (slot.UseTimer && !slot.UseCondition) { - var secondsElapsed = (int)(DateTime.UtcNow - this._player.StartTimestamp).TotalSeconds; - if (secondsElapsed > 0 && secondsElapsed % timerInterval == 0) + if (slot.TimerIntervalSeconds <= 0) { - return this._player.SkillList?.GetSkill((ushort)skillId); + return null; } + + var secondsSinceLastUse = (DateTime.UtcNow - slot.LastUseTime).TotalSeconds; + if (secondsSinceLastUse >= slot.TimerIntervalSeconds) + { + slot.LastUseTime = DateTime.UtcNow; + return this._player.SkillList?.GetSkill((ushort)slot.SkillId); + } + + return null; } - if (useCond) + if (slot.UseCondition && !slot.UseTimer) { - int threshold = subCond switch + int threshold = slot.SubCondition switch { 0 => 2, 1 => 3, @@ -344,15 +365,48 @@ private int CountMonstersNearby() _ => int.MaxValue, }; - if (this._nearbyMonsterCount >= threshold) + int monsterCount = slot.ConditionAttacking + ? this.CountMonstersAttackingPlayer() + : this._nearbyMonsterCount; + + if (monsterCount >= threshold) { - return this._player.SkillList?.GetSkill((ushort)skillId); + return this._player.SkillList?.GetSkill((ushort)slot.SkillId); } } return null; } + private int CountMonstersAttackingPlayer() + { + return this.GetAttackableMonstersInHuntingRange() + .Count(m => m.IsTargetingPlayer(this._player)); + } + + /// + /// Checks that the player has enough mana AND ability (AG) to cast the skill. + /// Both resources are consumed by skills via . + /// + private bool HasEnoughResources(SkillEntry? skillEntry) + { + if (skillEntry?.Skill is not { } skill || this._player.Attributes is null) + { + return true; + } + + foreach (var requirement in skill.ConsumeRequirements) + { + int required = this._player.GetRequiredValue(requirement); + if (this._player.Attributes[requirement.Attribute] < required) + { + return false; + } + } + + return true; + } + private async ValueTask ExecuteComboAttackAsync() { if (this._currentTarget is null) @@ -362,9 +416,9 @@ private async ValueTask ExecuteComboAttackAsync() } var ids = this.GetConfiguredComboSkillIds(); - if (ids.Count == 0) + if (ids.Count < MinComboSkillCount) { - await this.ExecuteAttackAsync(this._currentTarget, this.GetAnyOffensiveSkill(), false).ConfigureAwait(false); + await this.ExecuteAttackAsync(this._currentTarget).ConfigureAwait(false); return; } @@ -468,25 +522,7 @@ private byte GetEffectiveAttackRange() } } - return this.GetOffensiveSkills() - .Select(s => (byte)s.Skill!.Range) - .DefaultIfEmpty(DefaultRange) - .Max(); - } - - private IEnumerable GetOffensiveSkills() - { - return this._player.SkillList?.Skills - .Where(s => s.Skill is not null - && s.Skill.SkillType != SkillType.PassiveBoost - && s.Skill.SkillType != SkillType.Buff - && s.Skill.SkillType != SkillType.Regeneration) - ?? []; - } - - private SkillEntry? GetAnyOffensiveSkill() - { - return this.GetOffensiveSkills().FirstOrDefault(); + return DefaultRange; } private SkillEntry? FindDrainLifeSkill() @@ -494,4 +530,18 @@ private IEnumerable GetOffensiveSkills() return this._player.SkillList?.Skills.FirstOrDefault(s => s.Skill is { Number: DrainLifeBaseSkillId or DrainLifeStrengthenerSkillId or DrainLifeMasterySkillId }); } -} + + /// + /// Holds the configuration for one conditional skill slot, along with its mutable last-use timestamp. + /// + private sealed class ConditionalSkillSlot(int skillId, bool useTimer, int timerIntervalSeconds, bool useCondition, bool conditionAttacking, int subCondition) + { + public int SkillId { get; } = skillId; + public bool UseTimer { get; } = useTimer; + public int TimerIntervalSeconds { get; } = timerIntervalSeconds; + public bool UseCondition { get; } = useCondition; + public bool ConditionAttacking { get; } = conditionAttacking; + public int SubCondition { get; } = subCondition; + public DateTime LastUseTime { get; set; } + } +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/HealingHandler.cs b/src/GameLogic/OfflineLeveling/HealingHandler.cs index 3e74ba1cb..e755d62e0 100644 --- a/src/GameLogic/OfflineLeveling/HealingHandler.cs +++ b/src/GameLogic/OfflineLeveling/HealingHandler.cs @@ -10,6 +10,7 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions; +using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Interfaces; /// @@ -64,6 +65,9 @@ private async ValueTask PerformSelfHealingAsync() var healSkill = this.FindSkillByType(SkillType.Regeneration); if (healSkill is not null && this._config.AutoHeal) { + await this._player.ForEachWorldObserverAsync( + p => p.ShowSkillAnimationAsync(this._player, this._player, healSkill.Skill!, true), + includeThis: true).ConfigureAwait(false); await this._player.ApplyRegenerationAsync(this._player, healSkill).ConfigureAwait(false); return; } @@ -95,14 +99,18 @@ private async ValueTask PerformPartyHealingAsync() continue; } - if (!member.IsActive() || !this._player.IsInRange(member, 8)) + if (!member.IsActive() || !this._player.IsInRange(member, this._config!.HuntingRange)) { continue; } if (this.IsHealthBelowThreshold(member, this._config.HealPartyThresholdPercent)) { - await this._player.ApplyRegenerationAsync(member, healSkill).ConfigureAwait(false); + await this._player.ForEachWorldObserverAsync( + p => p.ShowSkillAnimationAsync(this._player, member, healSkill.Skill!, true), + includeThis: true).ConfigureAwait(false); + + await member.ApplyRegenerationAsync(this._player, healSkill).ConfigureAwait(false); } } } @@ -144,4 +152,4 @@ await ConsumeAction.HandleConsumeRequestAsync( { return this._player.SkillList?.Skills.FirstOrDefault(s => s.Skill?.SkillType == type); } -} +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/MovementHandler.cs b/src/GameLogic/OfflineLeveling/MovementHandler.cs index 8e33831c6..4b5ac2ccb 100644 --- a/src/GameLogic/OfflineLeveling/MovementHandler.cs +++ b/src/GameLogic/OfflineLeveling/MovementHandler.cs @@ -64,20 +64,6 @@ public async ValueTask RegroupAsync() return true; } - private bool ShouldRegroup(out double distance) - { - distance = this._player.GetDistanceTo(this._originPosition); - if (distance <= RegroupDistanceThreshold) - { - return false; - } - - this._outOfRangeSince ??= DateTime.UtcNow; - var secondsAway = (DateTime.UtcNow - this._outOfRangeSince.Value).TotalSeconds; - - return secondsAway >= this._config!.MaxSecondsAway || distance > this.HuntingRange; - } - /// /// Moves the player closer to a target within the specified range. /// @@ -97,7 +83,7 @@ public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range) /// /// The target position to walk to. /// True if the walk was successful; otherwise, false. - public async ValueTask WalkToAsync(Point target) + private async ValueTask WalkToAsync(Point target) { if (this._player.IsWalking || this._player.CurrentMap is not { } map) { @@ -131,4 +117,18 @@ public async ValueTask WalkToAsync(Point target) this._player.GameContext.PathFinderPool.Return(pathFinder); } } + + private bool ShouldRegroup(out double distance) + { + distance = this._player.GetDistanceTo(this._originPosition); + if (distance <= RegroupDistanceThreshold) + { + return false; + } + + this._outOfRangeSince ??= DateTime.UtcNow; + var secondsAway = (DateTime.UtcNow - this._outOfRangeSince.Value).TotalSeconds; + + return secondsAway >= this._config!.MaxSecondsAway || distance > this.HuntingRange; + } } diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs index c2b2e2c9a..9363a0971 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs @@ -14,15 +14,15 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; /// client disconnects. Mirrors the C++ CMuHelper::Work() loop including: /// /// Basic / conditional / combo skill attack selection -/// Self-buff application (up to 3 configured buff skills) -/// Auto-heal / drain-life based on HP % +/// Buff application (up to 3 configured buff skills) +/// Heal / drain-life based on HP % /// Return-to-origin regrouping /// Item pickup (Zen, Jewels, Excellent, Ancient, and named extra items) /// Skill and movement animations broadcast to nearby observers +/// Pet control /// -/// Party support is not implemented. /// -public sealed class OfflineLevelingIntelligence : IDisposable +public sealed class OfflineLevelingIntelligence : AsyncDisposable { private readonly OfflineLevelingPlayer _player; @@ -33,10 +33,11 @@ public sealed class OfflineLevelingIntelligence : IDisposable private readonly RepairHandler _repairHandler; private readonly ZenConsumptionHandler _zenHandler; private readonly HealingHandler _healingHandler; + private readonly PetHandler _petHandler; + private readonly CancellationTokenSource _cts = new(); private readonly EventHandler _deathHandler; private Timer? _aiTimer; - private bool _disposed; private bool _isDead; /// @@ -53,9 +54,10 @@ public OfflineLevelingIntelligence(OfflineLevelingPlayer player) this._healingHandler = new HealingHandler(player, config); this._itemPickupHandler = new ItemPickupHandler(player, config); this._movementHandler = new MovementHandler(player, config, originalPosition); - this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition); + this._combatHandler = new CombatHandler(player, config, this._movementHandler, this._buffHandler, originalPosition); this._repairHandler = new RepairHandler(player, config); this._zenHandler = new ZenConsumptionHandler(player); + this._petHandler = new PetHandler(player, config); if (config is null) { @@ -70,42 +72,52 @@ public OfflineLevelingIntelligence(OfflineLevelingPlayer player) this._player.Died += this._deathHandler; } - /// Starts the 500 ms AI timer. + /// Starts the 500 ms AI timer and a separate pet AI. public void Start() { + _ = this._petHandler.InitializeAsync(); + this._aiTimer ??= new Timer( - state => _ = this.SafeTickAsync(), + state => _ = this.SafeTickAsync(this._cts.Token), null, TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(500)); } /// - public void Dispose() + protected override async ValueTask DisposeAsyncCore() + { + await this._cts.CancelAsync().ConfigureAwait(false); + await this._petHandler.StopAsync().ConfigureAwait(false); + await base.DisposeAsyncCore().ConfigureAwait(false); + } + + /// + protected override void Dispose(bool disposing) { - if (this._disposed) + if (disposing) { - return; + this._player.Died -= this._deathHandler; + this._aiTimer?.Dispose(); + this._aiTimer = null; + this._cts.Dispose(); } - this._player.Died -= this._deathHandler; - this._disposed = true; - this._aiTimer?.Dispose(); - this._aiTimer = null; + base.Dispose(disposing); } private void OnPlayerDied(DeathInformation e) { this._player.Logger.LogDebug("Offline leveling player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName); this._isDead = true; + this._cts.Cancel(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")] - private async Task SafeTickAsync() + private async Task SafeTickAsync(CancellationToken cancellationToken) { try { - await this.TickAsync().ConfigureAwait(false); + await this.TickAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -113,17 +125,22 @@ private async Task SafeTickAsync() } catch (Exception ex) { - this._player.Logger.LogError(ex, "Error in offline leveling AI tick for {Name}.", this._player.Name); + this._player.Logger.LogError(ex, "Error in offline leveling AI tick for {AccountLoginName}.", this._player.AccountLoginName); } } - private async ValueTask TickAsync() + private async ValueTask TickAsync(CancellationToken cancellationToken) { if (await this.HandleDeathAsync().ConfigureAwait(false)) { return; } + if (cancellationToken.IsCancellationRequested) + { + return; + } + if (this._player.PlayerState.CurrentState != PlayerState.EnteredWorld) { return; @@ -132,6 +149,7 @@ private async ValueTask TickAsync() await this._zenHandler.DeductZenAsync().ConfigureAwait(false); await this._repairHandler.PerformRepairsAsync().ConfigureAwait(false); + await this._petHandler.CheckPetDurabilityAsync().ConfigureAwait(false); if (this.IsOnSkillCooldown()) { @@ -145,9 +163,7 @@ private async ValueTask TickAsync() } await this._healingHandler.PerformHealthRecoveryAsync().ConfigureAwait(false); - await this._combatHandler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false); - await this._itemPickupHandler.PickupItemsAsync().ConfigureAwait(false); if (this._player.IsWalking) diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs index 2fbcefc2e..0e613a2aa 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs @@ -15,6 +15,12 @@ public sealed class OfflineLevelingManager private readonly System.Collections.Concurrent.ConcurrentDictionary _activePlayers = new(StringComparer.OrdinalIgnoreCase); + /// + /// Gets a snapshot of all currently active offline leveling players. + /// + public IReadOnlyCollection OfflineLevelingPlayers + => this._activePlayers.Values.ToList(); + /// /// Starts an offline leveling session by replacing the real player with a ghost. /// diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs index 10274a806..cb7d924b8 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs @@ -7,9 +7,7 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.Views; -using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.PlugIns; -using System.Threading.Tasks; /// /// An offline player that continues leveling after the real client disconnects. @@ -27,10 +25,15 @@ public OfflineLevelingPlayer(IGameContext gameContext) { } + /// + /// Gets the login name this offline player belongs to. + /// + public string? AccountLoginName => this.Account?.LoginName; + /// /// Gets the start timestamp of the offline leveling session. /// - public DateTime StartTimestamp { get; private set; } + public DateTime StartTimestamp { get; internal set; } /// /// Initializes the offline player from captured references. @@ -64,11 +67,38 @@ public async ValueTask InitializeAsync(Account account, Character characte } catch (Exception ex) { - this.Logger.LogError(ex, "Failed to initialize offline player for {Name}.", this.Name); + this.Logger.LogError(ex, "Failed to initialize offline player for {player}.", this); return false; } } + /// + /// Stops the offline player and removes it from the world. + /// + public async ValueTask StopAsync() + { + if (this._intelligence is { } intelligence) + { + await intelligence.DisposeAsync().ConfigureAwait(false); + this._intelligence = null; + } + + try + { + await this.SaveProgressAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to save progress of offline leveling player {AccountLoginName}.", this.AccountLoginName); + } + + await this.DisconnectAsync().ConfigureAwait(false); + } + + /// + protected override ICustomPlugInContainer CreateViewPlugInContainer() + => new OfflineViewPlugInContainer(this); + private async ValueTask AdvanceToCharacterSelectionStateAsync() { // Advance state to allow the intelligence to perform actions. @@ -94,29 +124,4 @@ private void StartIntelligence() this._intelligence = new OfflineLevelingIntelligence(this); this._intelligence.Start(); } - - /// - /// Stops the offline player and removes it from the world. - /// - public async ValueTask StopAsync() - { - this._intelligence?.Dispose(); - this._intelligence = null; - try - { - await this.SaveProgressAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to save progress of offline leveling player {Name}.", this.Name); - } - - await this.DisconnectAsync().ConfigureAwait(false); - } - - /// - protected override ICustomPlugInContainer CreateViewPlugInContainer() - => new OfflineViewPlugInContainer(this); - - } \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/OfflineMapChangePlugIn.cs b/src/GameLogic/OfflineLeveling/OfflineMapChangePlugIn.cs index 25e8d2657..c8d55b7e3 100644 --- a/src/GameLogic/OfflineLeveling/OfflineMapChangePlugIn.cs +++ b/src/GameLogic/OfflineLeveling/OfflineMapChangePlugIn.cs @@ -4,8 +4,8 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; -using MUnique.OpenMU.GameLogic.Views.World; using System.Threading.Tasks; +using MUnique.OpenMU.GameLogic.Views.World; /// /// Simulates a map change response for an offline leveling character. diff --git a/src/GameLogic/OfflineLeveling/OfflineRespawnPlugIn.cs b/src/GameLogic/OfflineLeveling/OfflineRespawnPlugIn.cs index 5171a93c7..02ab8f4c9 100644 --- a/src/GameLogic/OfflineLeveling/OfflineRespawnPlugIn.cs +++ b/src/GameLogic/OfflineLeveling/OfflineRespawnPlugIn.cs @@ -4,8 +4,8 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; -using MUnique.OpenMU.GameLogic.Views.World; using System.Threading.Tasks; +using MUnique.OpenMU.GameLogic.Views.World; /// /// Simulates a respawn response for an offline leveling character. diff --git a/src/GameLogic/OfflineLeveling/OfflineViewPlugInContainer.cs b/src/GameLogic/OfflineLeveling/OfflineViewPlugInContainer.cs index 7bc7957d2..63b95e2cd 100644 --- a/src/GameLogic/OfflineLeveling/OfflineViewPlugInContainer.cs +++ b/src/GameLogic/OfflineLeveling/OfflineViewPlugInContainer.cs @@ -9,7 +9,7 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; using MUnique.OpenMU.PlugIns; /// -/// A plugin container for the , providing stub implementations +/// A plugin container for the , providing stub implementations /// of client-facing views necessary for successful safe-zone respawns. /// internal sealed class OfflineViewPlugInContainer : ICustomPlugInContainer diff --git a/src/GameLogic/OfflineLeveling/PetHandler.cs b/src/GameLogic/OfflineLeveling/PetHandler.cs new file mode 100644 index 000000000..4bc288c32 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/PetHandler.cs @@ -0,0 +1,116 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.Pet; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; + +/// +/// Handles pet behavior initialization and management for the offline leveling player. +/// +internal sealed class PetHandler +{ + private readonly OfflineLevelingPlayer _player; + private readonly IMuHelperSettings? _config; + private readonly IPetCommandManager? _petCommandManager; + + /// + /// Initializes a new instance of the class. + /// + /// The offline leveling player. + /// The MU Helper configuration. + /// Optional pet command manager for testing. + public PetHandler(OfflineLevelingPlayer player, IMuHelperSettings? config, IPetCommandManager? petCommandManager = null) + { + this._player = player; + this._config = config; + this._petCommandManager = petCommandManager; + } + + private IPetCommandManager? PetCommandManager => this._petCommandManager ?? this._player.PetCommandManager; + + /// + /// Initializes the dark raven behavior if configured. + /// The raven runs its own internal attack loop independently of the player's tick. + /// + public async ValueTask InitializeAsync() + { + try + { + await this.InitializeDarkRavenAsync().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // expected during shutdown + } + catch (Exception ex) + { + this._player.Logger.LogError(ex, "Error initializing pet for character {CharacterName}.", this._player.Name); + } + } + + /// + /// Checks the pet durability and sets the pet to idle if it has run out. + /// + public async ValueTask CheckPetDurabilityAsync() + { + if (this.PetCommandManager is null) + { + return; + } + + try + { + if (this._player.Inventory?.GetItem(InventoryConstants.PetSlot) is { Durability: 0 }) + { + await this.PetCommandManager.SetBehaviourAsync(PetBehaviour.Idle, null).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // expected during shutdown + } + catch (Exception ex) + { + this._player.Logger.LogError(ex, "Error checking pet durability for {AccountLoginName}.", this._player.AccountLoginName); + } + } + + /// + /// Stops the pet behavior. + /// + public async ValueTask StopAsync() + { + if (this.PetCommandManager is { } petCommandManager) + { + try + { + await petCommandManager.SetBehaviourAsync(PetBehaviour.Idle, null).ConfigureAwait(false); + } + catch (Exception ex) + { + this._player.Logger.LogError(ex, "Error stopping pet for {AccountLoginName}.", this._player.AccountLoginName); + } + } + } + + private async ValueTask InitializeDarkRavenAsync() + { + if (this._config is not { UseDarkRaven: true } || this.PetCommandManager is not { } petCommandManager) + { + return; + } + + var behaviour = this._config.DarkRavenMode switch + { + 1 => PetBehaviour.AttackRandom, + 2 => PetBehaviour.AttackWithOwner, + _ => PetBehaviour.Idle, + }; + + await petCommandManager.SetBehaviourAsync(behaviour, null).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/RepairHandler.cs b/src/GameLogic/OfflineLeveling/RepairHandler.cs index 7acf0bea5..7091dcabd 100644 --- a/src/GameLogic/OfflineLeveling/RepairHandler.cs +++ b/src/GameLogic/OfflineLeveling/RepairHandler.cs @@ -47,6 +47,11 @@ public async ValueTask PerformRepairsAsync() continue; } + if (this._player.Inventory?.GetItem(i) is null) + { + continue; + } + await this._repairAction.RepairItemAsync(this._player, i).ConfigureAwait(false); } } diff --git a/src/GameLogic/OfflinePartyMember.cs b/src/GameLogic/OfflinePartyMember.cs new file mode 100644 index 000000000..7841f1c84 --- /dev/null +++ b/src/GameLogic/OfflinePartyMember.cs @@ -0,0 +1,83 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.PlugIns; +using Nito.AsyncEx; + +/// +/// A party member who is currently offline. +/// +public sealed class OfflinePartyMember : IPartyMember +{ + /// + /// Initializes a new instance of the class. + /// + /// The player. + public OfflinePartyMember(IPartyMember player) + { + this.Name = player.Name; + this.Id = player.Id; + this.CurrentHealth = player.CurrentHealth; + this.MaximumHealth = player.MaximumHealth; + this.CurrentMap = player.CurrentMap; + this.Position = player.Position; + this.Logger = player?.Logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + } + + /// + public Party? Party { get; set; } + + /// + public IPartyMember? LastPartyRequester { get; set; } + + /// + public uint MaximumHealth { get; } + + /// + public uint CurrentHealth { get; } + + /// + public string Name { get; } + + /// + public bool IsConnected => false; + + /// + public ushort Id { get; } + + /// + public Point Position { get; set; } + + /// + public GameMap? CurrentMap { get; } + + /// + public ICustomPlugInContainer ViewPlugIns { get; } = new DummyViewPlugInContainer(); + + /// + public AsyncReaderWriterLock ObserverLock { get; } = new(); + + /// + public ISet Observers { get; } = new HashSet(); + + /// + public ILogger Logger { get; } + + /// + public ValueTask AddObserverAsync(IWorldObserver observer) => ValueTask.CompletedTask; + + /// + public ValueTask RemoveObserverAsync(IWorldObserver observer) => ValueTask.CompletedTask; + + private class DummyViewPlugInContainer : ICustomPlugInContainer + { + public T? GetPlugIn() + where T : class, IViewPlugIn => null; + } +} diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs index 45bd8fb0a..4c059ef69 100644 --- a/src/GameLogic/Party.cs +++ b/src/GameLogic/Party.cs @@ -10,52 +10,54 @@ namespace MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.Party; +using MUnique.OpenMU.Persistence; using Nito.AsyncEx; -using Nito.AsyncEx.Synchronous; /// -/// The party object. Contains a group of players who can chat with each other, and get information about the health status of their party mates. +/// A group of players who share chat, health visibility, and experience distribution. /// -public sealed class Party : Disposable +public sealed class Party : AsyncDisposable { private static readonly Meter Meter = new(MeterName); - private static readonly Counter PartyCount = Meter.CreateCounter("PartyCount"); private readonly ILogger _logger; - - private readonly Timer _healthUpdate; - + private readonly IPartyManager _partyManager; private readonly byte _maxPartySize; - + private readonly List _partyList; + private readonly AsyncLock _distributionLock = new(); private readonly List _distributionList; + private readonly TimeSpan _healthUpdateInterval = TimeSpan.FromMilliseconds(500); + private readonly Task? _healthUpdateTask; - private readonly AsyncLock _distributionLock = new AsyncLock(); + private CancellationTokenSource? _healthUpdateCts; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// + /// The party manager for membership tracking. /// Maximum size of the party. - /// Logger of this party. - public Party(byte maxPartySize, ILogger logger) + /// Logger for party events. + public Party(IPartyManager partyManager, byte maxPartySize, ILogger logger) { + this._partyManager = partyManager; this._maxPartySize = maxPartySize; this._logger = logger; + this._partyList = new List(maxPartySize); + this._distributionList = new List(maxPartySize); - this.PartyList = new List(maxPartySize); - this._distributionList = new List(this.MaxPartySize); - var updateInterval = new TimeSpan(0, 0, 0, 0, 500); - this._healthUpdate = new Timer(this.HealthUpdateElapsed, null, updateInterval, updateInterval); + this._healthUpdateCts = new CancellationTokenSource(); + this._healthUpdateTask = this.HealthUpdateLoopAsync(this._healthUpdateCts.Token); PartyCount.Add(1); } /// - /// Gets the party list. + /// Gets the party members. /// - public IList PartyList { get; } + public IReadOnlyList PartyList => this._partyList; /// - /// Gets the maximum size of the party. + /// Gets the maximum party size. /// public byte MaxPartySize => this._maxPartySize; @@ -64,92 +66,133 @@ public Party(byte maxPartySize, ILogger logger) /// public IPartyMember? PartyMaster { get; private set; } + private static string MeterName => typeof(Party).FullName ?? nameof(Party); + /// - /// Gets the name of the meter of this class. + /// Adds a new member to the party. /// - internal static string MeterName => typeof(Party).FullName ?? nameof(Party); + /// The member to add. + /// True if the member was added successfully; false if the party is full. + public async ValueTask AddAsync(IPartyMember newMember) + { + if (this._partyList.Count >= this._maxPartySize) + { + return false; + } + + if (this._partyList.Count == 0) + { + this.PartyMaster = newMember; + } + + this._partyList.Add(newMember); + newMember.Party = this; + this._partyManager.TrackMembership(newMember.Name, this); + + await this.SendPartyListAsync().ConfigureAwait(false); + await this.UpdateNearbyCountAsync().ConfigureAwait(false); + return true; + } /// - /// Kicks the player from the party. + /// Replaces with in-place, + /// preserving the member's slot index and master status. /// - /// The sender. - public async ValueTask KickMySelfAsync(IPartyMember sender) + /// The member to replace. + /// The new member to insert. + public async ValueTask ReplaceMemberAsync(IPartyMember oldMember, IPartyMember newMember) { - for (int i = 0; i < this.PartyList.Count; i++) + var index = this._partyList.IndexOf(oldMember); + if (index < 0) { - if (this.PartyList[i].Id == sender.Id) - { - await this.ExitPartyAsync(this.PartyList[i], (byte)i).ConfigureAwait(false); - return; - } + return; + } + + this._partyList[index] = newMember; + newMember.Party = this; + oldMember.Party = null; + + this._partyManager.UntrackMembership(oldMember.Name); + this._partyManager.TrackMembership(newMember.Name, this); + + if (this.PartyMaster == oldMember) + { + this.PartyMaster = newMember; } + + if (oldMember is Player oldPlayer && oldPlayer.Attributes is { } oldAttr) + { + oldAttr[Stats.NearbyPartyMemberCount] = 0; + } + + await this.SendPartyListAsync().ConfigureAwait(false); + await this.UpdateNearbyCountAsync().ConfigureAwait(false); + } + + /// + /// Replaces the live member with an snapshot, + /// keeping the party slot reserved for reconnection. + /// + /// The member who is leaving temporarily. + public ValueTask LeaveTemporarilyAsync(IPartyMember member) + { + var snapshot = new OfflinePartyMember(member); + return this.ReplaceMemberAsync(member, snapshot); } /// - /// Kicks the player from the party. + /// Kicks the member at the given index. /// /// The party list index of the member to kick. public async ValueTask KickPlayerAsync(byte index) { - var toKick = this.PartyList[index]; + var toKick = this._partyList[index]; await this.ExitPartyAsync(toKick, index).ConfigureAwait(false); } /// - /// Adds the specified new party mate. + /// Allows a member to kick themselves. /// - /// The new party mate. - /// True, if adding was successful; Otherwise, false. - public async ValueTask AddAsync(IPartyMember newPartyMate) + /// The member who initiated the kick. + public async ValueTask KickMySelfAsync(IPartyMember sender) { - if (this.PartyList.Count >= this._maxPartySize) - { - return false; - } - - if (this.PartyList.Count == 0) + var index = this._partyList.IndexOf(sender); + if (index >= 0) { - this.PartyMaster = newPartyMate; + await this.ExitPartyAsync(sender, (byte)index).ConfigureAwait(false); } - - this.PartyList.Add(newPartyMate); - newPartyMate.Party = this; - await this.SendPartyListAsync().ConfigureAwait(false); - await this.UpdateNearbyCountAsync().ConfigureAwait(false); - return true; } /// - /// Sends the chat message to all party members. + /// Sends a chat message to all party members. /// - /// The message. - /// The sender character name. + /// The message to send. + /// The name of the sending character. public async ValueTask SendChatMessageAsync(string message, string senderCharacterName) { - for (int i = 0; i < this.PartyList.Count; i++) + foreach (var member in this._partyList) { try { - await this.PartyList[i].InvokeViewPlugInAsync(p => p.ChatMessageAsync(message, senderCharacterName, ChatMessageType.Party)).ConfigureAwait(false); + await member.InvokeViewPlugInAsync( + p => p.ChatMessageAsync(message, senderCharacterName, ChatMessageType.Party)).ConfigureAwait(false); } catch (Exception ex) { - this._logger.LogDebug(ex, "Error sending the chat message"); + this._logger.LogDebug(ex, "Error sending chat message to {Name}", member.Name); } } } /// - /// Distributes the experience after kill. + /// Distributes experience to nearby party members after a kill. /// - /// The object which was killed. - /// The killer which is member of the party. All players which observe the killer, get experience. - /// - /// The total distributed experience to all party members. - /// + /// The object that was killed. + /// The killer who is a party member. + /// The total experience distributed. public async ValueTask DistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer) { - using var d = await this._distributionLock.LockAsync(); + using var _ = await this._distributionLock.LockAsync(); try { return await this.InternalDistributeExperienceAfterKillAsync(killedObject, killer).ConfigureAwait(false); @@ -161,19 +204,33 @@ public async ValueTask DistributeExperienceAfterKillAsync(IAttackable kille } /// - /// Distributes the money after a kill. + /// Distributes money to nearby party members after a kill. /// - /// The object which was killed. - /// The killer which is member of the party. All players which observe the killer, get experience. - /// The amount of money which should be distributed. - public async ValueTask DistributeMoneyAfterKillAsync(IAttackable killedObject, IPartyMember killer, uint amount) + /// The object that was killed. + /// The killer who is a party member. + /// The amount of money to distribute. + public async ValueTask DistributeMoneyAfterKillAsync(IAttackable killed, IPartyMember killer, uint amount) { - using var d = await this._distributionLock.LockAsync(); + using var _ = await this._distributionLock.LockAsync(); try { - this._distributionList.AddRange(this.PartyList.OfType().Where(p => p.CurrentMap == killer.CurrentMap && !p.IsAtSafezone() && p.Attributes is { })); + this._logger.LogDebug("Distributing money after killing {name}", killed.GetName()); + this._distributionList.AddRange( + this._partyList.OfType() + .Where(p => p.CurrentMap == killer.CurrentMap + && !p.IsAtSafezone() + && p.Attributes is { })); + + if (this._distributionList.Count == 0) + { + return; + } + var moneyPart = amount / this._distributionList.Count; - this._distributionList.ForEach(p => p.TryAddMoney((int)(moneyPart * p.Attributes![Stats.MoneyAmountRate]))); + foreach (var player in this._distributionList) + { + player.TryAddMoney((int)(moneyPart * player.Attributes![Stats.MoneyAmountRate])); + } } finally { @@ -182,40 +239,35 @@ public async ValueTask DistributeMoneyAfterKillAsync(IAttackable killedObject, I } /// - /// Gets the quest drop item groups for the whole party. + /// Gets drop item groups from nearby party members' active quests. /// - /// The killer. - /// The list of which should be considered when generating a drop. + /// The party member who made the kill. + /// A list of drop item groups from nearby party members' active quests. public async ValueTask> GetQuestDropItemGroupsAsync(IPartyMember killer) { - using var d = await this._distributionLock.LockAsync(); + using var _ = await this._distributionLock.LockAsync(); try { using (await killer.ObserverLock.ReaderLockAsync().ConfigureAwait(false)) { this._distributionList.AddRange( - this.PartyList.OfType() + this._partyList.OfType() .Where(p => p.CurrentMap == killer.CurrentMap && !p.IsAtSafezone() && p.IsAlive && (p == killer || killer.Observers.Contains(p)))); } - IList result = []; - - var dropItemGroups = this._distributionList - .SelectMany(m => m.SelectedCharacter?.GetQuestDropItemGroups() ?? Enumerable.Empty()); - foreach (var dropItemGroup in dropItemGroups) + if (this._distributionList.Count == 0) { - if (result.Count == 0) - { - result = new List(); - } - - result.Add(dropItemGroup); + return []; } - return result; + var result = this._distributionList + .SelectMany(m => m.SelectedCharacter?.GetQuestDropItemGroups() ?? []) + .ToList(); + + return result.Count == 0 ? [] : result; } finally { @@ -224,185 +276,218 @@ public async ValueTask> GetQuestDropItemGroupsAsync(IPartyM } /// - protected override void Dispose(bool disposing) + protected override async ValueTask DisposeAsyncCore() { - if (this.PartyList.Count > 0) + if (this._healthUpdateCts is { } cts) + { + await cts.CancelAsync().ConfigureAwait(false); + } + + if (this._healthUpdateTask is { } task) + { + await task.ConfigureAwait(false); + } + + for (byte i = 0; i < this._partyList.Count; i++) { - for (byte i = 0; i < this.PartyList.Count; i++) + var member = this._partyList[i]; + try { - try - { - var index = i; - this.PartyList[i].InvokeViewPlugInAsync(p => p.PartyMemberRemovedAsync(index)).AsTask().WaitWithoutException(); - this.PartyList[i].Party = null; - } - catch (Exception ex) - { - this._logger.LogDebug(ex, "error at dispose"); - } + var index = i; + await member.InvokeViewPlugInAsync( + p => p.PartyMemberRemovedAsync(index)).ConfigureAwait(false); + member.Party = null; + } + catch (Exception ex) + { + this._logger.LogDebug(ex, "Error notifying {Name} of party dissolution", member.Name); } - - this.PartyList.Clear(); } - this._healthUpdate.Dispose(); + this._partyList.Clear(); PartyCount.Add(-1); + + await base.DisposeAsyncCore().ConfigureAwait(false); } - private async ValueTask InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer) + /// + protected override void Dispose(bool disposing) { - if (killedObject.IsSummonedMonster) - { - // Do not award experience or drop items for summoned monsters. - return 0; - } - - using (await killer.ObserverLock.ReaderLockAsync()) + if (disposing) { - // All players in the range of the player are getting experience. - // This might not be like in the original server, where observing the killed monster counts, - // but at this stage, the monster already has cleared his observers. - this._distributionList.AddRange(this.PartyList.OfType().Where(p => p == killer || killer.Observers.Contains(p))); + this._healthUpdateCts?.Dispose(); + this._healthUpdateCts = null; } - var count = this._distributionList.Count; - if (count == 0) - { - return count; - } + base.Dispose(disposing); + } - var totalLevel = this._distributionList.Sum(p => (int)p.Attributes![Stats.TotalLevel]); + private static (int Total, int PerLevel) CalculatePartyExperience(List recipients, IAttackable killed) + { + var count = recipients.Count; + var totalLevel = recipients.Sum(p => (int)p.Attributes![Stats.TotalLevel]); var averageLevel = totalLevel / count; - var averageExperience = killedObject.CalculateBaseExperience(averageLevel); - var totalAverageExperience = averageExperience * count * Math.Pow(1.05, count - 1); - totalAverageExperience *= killedObject.CurrentMap?.Definition.ExpMultiplier ?? 1; - totalAverageExperience *= this._distributionList.First().GameContext.ExperienceRate; + var baseExp = killed.CalculateBaseExperience(averageLevel); - var randomizedTotalExperience = Rand.NextInt((int)(totalAverageExperience * 0.8), (int)(totalAverageExperience * 1.2)); - var randomizedTotalExperiencePerLevel = randomizedTotalExperience / totalLevel; - foreach (var player in this._distributionList) + var totalAvg = baseExp * count * Math.Pow(1.05, count - 1); + totalAvg *= killed.CurrentMap?.Definition.ExpMultiplier ?? 1; + totalAvg *= recipients[0].GameContext.ExperienceRate; + + var total = Rand.NextInt((int)(totalAvg * 0.8), (int)(totalAvg * 1.2)); + var perLevel = total / totalLevel; + + return (total, perLevel); + } + + private static async ValueTask AwardExperienceAsync(Player player, int perLevel, IAttackable killed) + { + var isMasterAtCap = + (short)player.Attributes![Stats.Level] == player.GameContext.Configuration.MaximumLevel + && (player.SelectedCharacter?.CharacterClass?.IsMasterClass ?? false); + + var levelStat = isMasterAtCap ? Stats.TotalLevel : Stats.Level; + var rateStat = isMasterAtCap ? Stats.MasterExperienceRate : Stats.ExperienceRate; + + var exp = (int)(perLevel + * player.Attributes![levelStat] + * (player.Attributes[rateStat] + player.Attributes[Stats.BonusExperienceRate])); + + if (isMasterAtCap) { - if ((short)player.Attributes![Stats.Level] == player.GameContext.Configuration.MaximumLevel) - { - if (player.SelectedCharacter?.CharacterClass?.IsMasterClass ?? false) - { - var expMaster = (int)(randomizedTotalExperiencePerLevel * player.Attributes![Stats.TotalLevel] * (player.Attributes[Stats.MasterExperienceRate] + player.Attributes[Stats.BonusExperienceRate])); - await player.AddMasterExperienceAsync(expMaster, killedObject).ConfigureAwait(false); - } - } - else - { - var exp = (int)(randomizedTotalExperiencePerLevel * player.Attributes![Stats.Level] * (player.Attributes[Stats.ExperienceRate] + player.Attributes[Stats.BonusExperienceRate])); - await player.AddExperienceAsync(exp, killedObject).ConfigureAwait(false); - } + await player.AddMasterExperienceAsync(exp, killed).ConfigureAwait(false); + } + else + { + await player.AddExperienceAsync(exp, killed).ConfigureAwait(false); } - - return randomizedTotalExperience; } - private async ValueTask ExitPartyAsync(IPartyMember player, byte index) + private async ValueTask ExitPartyAsync(IPartyMember member, byte index) { - if (this.PartyList.Count < 3 || Equals(this.PartyMaster, player)) + var remainingCount = this._partyList.Count(m => m != member); + if (remainingCount < 2) { - this.Dispose(); + await this.DisposeAsync().ConfigureAwait(false); return; } - this.PartyList.Remove(player); - player.Party = null; + this._partyList.Remove(member); + member.Party = null; + this._partyManager.UntrackMembership(member.Name); + + if (member is Player player && player.Attributes is { } attributes) + { + attributes[Stats.NearbyPartyMemberCount] = 0; + } + try { - await player.InvokeViewPlugInAsync(p => p.PartyMemberRemovedAsync(index)).ConfigureAwait(false); + await member.InvokeViewPlugInAsync( + p => p.PartyMemberRemovedAsync(index)).ConfigureAwait(false); } catch (Exception ex) { - this._logger.LogDebug(ex, "Error when calling PartyMemberRemoved. Already disconnected?"); + this._logger.LogDebug(ex, "Error notifying kicked member {Name}", member.Name); } await this.SendPartyListAsync().ConfigureAwait(false); await this.UpdateNearbyCountAsync().ConfigureAwait(false); - if (player is Player actualPlayer && actualPlayer.Attributes is { } attributes) - { - attributes[Stats.NearbyPartyMemberCount] = 0; - } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] - private async void HealthUpdateElapsed(object? state) + private async ValueTask InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer) { - try + if (killedObject.IsSummonedMonster) { - var partyMaster = this.PartyList.FirstOrDefault(); - if (partyMaster is null) - { - return; - } + return 0; + } - bool updateNeeded = partyMaster.ViewPlugIns.GetPlugIn()?.IsHealthUpdateNeeded() ?? false; - if (updateNeeded) - { - await partyMaster.InvokeViewPlugInAsync(p => p.UpdatePartyHealthAsync()).ConfigureAwait(false); - for (var i = this.PartyList.Count - 1; i >= 1; i--) - { - var member = this.PartyList[i]; - var plugIn = member.ViewPlugIns.GetPlugIn(); - if (plugIn?.IsHealthUpdateNeeded() ?? false) - { - await plugIn.UpdatePartyHealthAsync().ConfigureAwait(false); - } - } - } + using (await killer.ObserverLock.ReaderLockAsync()) + { + this._distributionList.AddRange( + this._partyList.OfType() + .Where(p => p == killer || killer.Observers.Contains(p))); } - catch (Exception ex) + + if (this._distributionList.Count == 0) { - this._logger.LogDebug(ex, "Unexpected error during health update"); + return 0; } + + var (total, perLevel) = CalculatePartyExperience(this._distributionList, killedObject); + + foreach (var player in this._distributionList) + { + await AwardExperienceAsync(player, perLevel, killedObject).ConfigureAwait(false); + } + + return total; } + private async ValueTask UpdateNearbyCountAsync() { - if (this.PartyList.Count == 0) + foreach (var member in this._partyList) { - return; - } + if (member is not Player player || player.Attributes is not { } attributes) + { + continue; + } - for (byte i = 0; i < this.PartyList.Count; i++) - { try { - if (this.PartyList[i] is not Player player || player.Attributes is not { } attributes) - { - continue; - } - - using var readerLock = await player.ObserverLock.ReaderLockAsync().ConfigureAwait(false); - - attributes[Stats.NearbyPartyMemberCount] = this.PartyList.Count(player.Observers.Contains); + using var _ = await player.ObserverLock.ReaderLockAsync().ConfigureAwait(false); + attributes[Stats.NearbyPartyMemberCount] = this._partyList.Count(player.Observers.Contains); } catch (Exception ex) { - this._logger.LogDebug(ex, "Error updating {statsName}", nameof(Stats.NearbyPartyMemberCount)); + this._logger.LogDebug(ex, "Error updating {Stat} for {Name}", nameof(Stats.NearbyPartyMemberCount), player.Name); } } } private async ValueTask SendPartyListAsync() { - if (this.PartyList.Count == 0) - { - return; - } - - for (byte i = 0; i < this.PartyList.Count; i++) + foreach (var member in this._partyList) { try { - await this.PartyList[i].InvokeViewPlugInAsync(p => p.UpdatePartyListAsync()).ConfigureAwait(false); + await member.InvokeViewPlugInAsync( + p => p.UpdatePartyListAsync()).ConfigureAwait(false); } catch (Exception ex) { - this._logger.LogDebug(ex, "Error sending party list update"); + this._logger.LogDebug(ex, "Error sending party list to {Name}", member.Name); } } } + + private async Task HealthUpdateLoopAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(this._healthUpdateInterval); + try + { + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + foreach (var member in this._partyList) + { + var plugIn = member.ViewPlugIns.GetPlugIn(); + if (plugIn?.IsHealthUpdateNeeded() is true) + { + await plugIn.UpdatePartyHealthAsync().ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Unexpected error during health update"); + } + } + } + catch (OperationCanceledException) + { + // Expected during shutdown. + } + } } \ No newline at end of file diff --git a/src/GameLogic/PartyManager.cs b/src/GameLogic/PartyManager.cs new file mode 100644 index 000000000..7c71b6fca --- /dev/null +++ b/src/GameLogic/PartyManager.cs @@ -0,0 +1,62 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.GameLogic.Views.Party; + +/// +/// Manages party creation and tracks character-to-party membership for member reconnection. +/// +public sealed class PartyManager : IPartyManager +{ + private readonly System.Collections.Concurrent.ConcurrentDictionary _partyByCharacterName = new(StringComparer.Ordinal); + private readonly ILogger _partyLogger; + private readonly byte _maxPartySize; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of members per party. + /// The logger for party instances. + public PartyManager(byte maxPartySize, ILogger partyLogger) + { + this._maxPartySize = maxPartySize; + this._partyLogger = partyLogger; + } + + /// + public Party CreateParty() => new(this, this._maxPartySize, this._partyLogger); + + /// + public async ValueTask OnMemberReconnectedAsync(IPartyMember member) + { + if (!this._partyByCharacterName.TryGetValue(member.Name, out var party)) + { + return; + } + + // Find the offline snapshot that was created when the member disconnected. + var snapshot = party.PartyList.FirstOrDefault(m => m.Name == member.Name && !m.IsConnected); + if (snapshot is null) + { + // Already replaced (e.g., duplicate reconnect event) — nothing to do. + return; + } + + await party.ReplaceMemberAsync(snapshot, member).ConfigureAwait(false); + + // Send the full party state to the rejoined player since they missed updates while offline. + await member.InvokeViewPlugInAsync(p => p.UpdatePartyListAsync()).ConfigureAwait(false); + await member.InvokeViewPlugInAsync(p => p.UpdatePartyHealthAsync()).ConfigureAwait(false); + } + + /// + void IPartyManager.TrackMembership(string characterName, Party party) + => this._partyByCharacterName[characterName] = party; + + /// + void IPartyManager.UntrackMembership(string characterName) + => this._partyByCharacterName.TryRemove(characterName, out _); +} \ No newline at end of file diff --git a/src/GameLogic/PathFinderPoolingPolicy.cs b/src/GameLogic/PathFinderPoolingPolicy.cs index 62ba960ba..2afdcc4cd 100644 --- a/src/GameLogic/PathFinderPoolingPolicy.cs +++ b/src/GameLogic/PathFinderPoolingPolicy.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 34ffd39c6..39eb63ea1 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -345,7 +345,7 @@ private set public ComboStateMachine? ComboState => this.Attributes?[Stats.IsSkillComboAvailable] > 0 ? this._comboStateLazy?.Value : null; /// - /// Gets the summon. + /// Gets the player summon. /// public (Monster, INpcIntelligence)? Summon { get; private set; } @@ -358,6 +358,9 @@ private set /// public Party? Party { get; set; } + /// + public bool IsConnected => !this.PlayerState.CurrentState.IsDisconnectedOrFinished(); + /// public bool IsAlive { get; set; } @@ -579,6 +582,7 @@ public async ValueTask SetSelectedCharacterAsync(Character? character) } this.DuelRoom = null; + this._selectedCharacter = null; } else @@ -1081,7 +1085,7 @@ public async ValueTask WarpToAsync(ExitGate gate) /// Respawns the player to the specified gate. /// /// The gate at which the player should be respawned. - public async ValueTask RespawnAtAsync(ExitGate gate) + public virtual async ValueTask RespawnAtAsync(ExitGate gate) { var isRespawnOnSameMap = object.Equals(this.CurrentMap?.Definition, gate.Map); @@ -1690,7 +1694,7 @@ IElement AppedMasterSkillPowerUp(SkillEntry masterSkillEntry, PowerUpDefinition /// Creates a summoned monster for the player. /// /// The definition. - /// Can't add a summon for a player which isn't spawned yet. + /// Can't add the player summon for a player which isn't spawned yet. public async ValueTask CreateSummonedMonsterAsync(MonsterDefinition definition) { if (this.CurrentMap is not { } gameMap) @@ -1722,7 +1726,7 @@ public async ValueTask CreateSummonedMonsterAsync(MonsterDefinition definition) } /// - /// Notifies the player object that the summon died. + /// Notifies the player object that the summoned monster died. /// public void SummonDied() { @@ -1730,7 +1734,7 @@ public void SummonDied() } /// - /// Removes the summon. + /// Removes the player summon. /// public async ValueTask RemoveSummonAsync() { @@ -1797,33 +1801,14 @@ public async ValueTask ResetPetBehaviorAsync() /// public async ValueTask RemoveFromGameAsync() { - var moveToNextSafezone = false; - if (this._respawnAfterDeathCts is { IsCancellationRequested: false }) - { - await this._respawnAfterDeathCts.CancelAsync().ConfigureAwait(false); - moveToNextSafezone = true; - } - - if (this.CurrentMiniGame is { }) + if (this.Party is { } party) { - moveToNextSafezone = true; + await party.LeaveTemporarilyAsync(this).ConfigureAwait(false); } - if (this.DuelRoom is { }) - { - moveToNextSafezone = true; - } - - if (moveToNextSafezone) - { - await this.WarpToSafezoneAsync().ConfigureAwait(false); - } + await this.HandleMoveToNextSafezoneAsync().ConfigureAwait(false); await this.RemoveFromCurrentMapAsync().ConfigureAwait(false); - if (this.Party is { } party) - { - await party.KickMySelfAsync(this).ConfigureAwait(false); - } await this.RestoreTemporaryStorageItemsAsync().ConfigureAwait(false); @@ -1868,11 +1853,6 @@ protected override async ValueTask DisposeAsyncCore() this.PersistenceContext.Dispose(); await this.RemoveFromCurrentMapAsync().ConfigureAwait(false); - if (this.Party is { } party) - { - await party.KickMySelfAsync(this).ConfigureAwait(false); - } - await this._observerToWorldViewAdapter.ClearObservingObjectsListAsync().ConfigureAwait(false); this._observerToWorldViewAdapter.Dispose(); this._walker.Dispose(); @@ -1905,6 +1885,35 @@ protected virtual ICustomPlugInContainer CreateViewPlugInContainer( throw new NotImplementedException("CreateViewPlugInContainer must be overwritten in derived classes."); } + /// + /// Handles the move to next safezone logic after death or disconnect. + /// + private async ValueTask HandleMoveToNextSafezoneAsync() + { + bool moveToNextSafezone = false; + + if (this._respawnAfterDeathCts is { IsCancellationRequested: false }) + { + await this._respawnAfterDeathCts.CancelAsync().ConfigureAwait(false); + moveToNextSafezone = true; + } + + if (this.CurrentMiniGame is { }) + { + moveToNextSafezone = true; + } + + if (this.DuelRoom is { }) + { + moveToNextSafezone = true; + } + + if (moveToNextSafezone) + { + await this.WarpToSafezoneAsync().ConfigureAwait(false); + } + } + private async ValueTask TryRemoveFromCurrentMapAsync(bool willRespawnOnSameMap) { var currentMap = this.CurrentMap; @@ -1950,10 +1959,10 @@ private void PlaceAtGate(ExitGate gate) private async ValueTask RemoveFromCurrentMapAsync() { - if (this.CurrentMap != null) + if (this._currentMap is { } map) { - await this.CurrentMap.RemoveAsync(this).ConfigureAwait(false); - this.CurrentMap = null; + await map.RemoveAsync(this).ConfigureAwait(false); + this._currentMap = null; } } @@ -2223,6 +2232,12 @@ async Task RespawnAsync(CancellationToken cancellationToken) try { await Task.Delay(3000, cancellationToken).ConfigureAwait(false); + + if (cancellationToken.IsCancellationRequested || this.CurrentMap is null) + { + return; + } + if (this.Summon?.Item1 is { } summon) { await summon.CurrentMap.RemoveAsync(summon).ConfigureAwait(false); @@ -2745,6 +2760,22 @@ private async ValueTask CloseTradeIfNeededAsync() } } + /// + /// A used to apply the GM mark + /// to a with status. + /// + private protected sealed class GMMagicEffectDefinition : MagicEffectDefinition + { + /// + /// Initializes a new instance of the class + /// with an empty power-up definitions list. + /// + public GMMagicEffectDefinition() + { + this.PowerUpDefinitions = new List(0); + } + } + private sealed class TemporaryItemStorage : ItemStorage { public TemporaryItemStorage() @@ -2795,12 +2826,4 @@ public void RaiseAppearanceChanged() this.AppearanceChanged?.Invoke(this, EventArgs.Empty); } } - - private protected sealed class GMMagicEffectDefinition : MagicEffectDefinition - { - public GMMagicEffectDefinition() - { - this.PowerUpDefinitions = new List(0); - } - } } \ No newline at end of file diff --git a/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs b/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs index 97f67dbf5..de2d44bb7 100644 --- a/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs +++ b/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -58,7 +58,7 @@ public async ValueTask HandleResponseAsync(Player player, bool accepted) else { var master = player.LastPartyRequester; - var party = new Party(player.GameContext.Configuration.MaximumPartySize, player.GameContext.LoggerFactory.CreateLogger()); + var party = player.GameContext.PartyManager.CreateParty(); await party.AddAsync(master).ConfigureAwait(false); await party.AddAsync(player).ConfigureAwait(false); } diff --git a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs index 31b44f6f3..a0bac8588 100644 --- a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs +++ b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs @@ -51,7 +51,6 @@ public async ValueTask AttackAsync(Player player, ushort extraTargetId, ushort s || (skill.SkillType is SkillType.AreaSkillExplicitHits && hitImplicitlyForExplicitSkill)) { // todo: delayed automatic hits, like evil spirit, flame, triple shot... when hitImplicitlyForExplicitSkill = true. - await this.PerformAutomaticHitsAsync(player, extraTargetId, targetAreaCenter, skillEntry!, skill, rotation).ConfigureAwait(false); } @@ -78,6 +77,9 @@ private static IEnumerable GetTargets(Player player, Point targetAr var isExtraTargetDefined = extraTargetId != UndefinedTarget; var extraTarget = isExtraTargetDefined ? player.GetObject(extraTargetId) as IAttackable : null; + player.Logger.LogDebug( + "GetTargets: skill={Skill}, extraTargetId={ExtraTargetId}, extraTarget={ExtraTarget}", skill.Name, extraTargetId, extraTarget?.ToString() ?? "null"); + if (skill.SkillType == SkillType.AreaSkillExplicitTarget) { if (extraTarget?.CheckSkillTargetRestrictions(player, skill) is true diff --git a/src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs b/src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs index c48c0f685..8b5d51f99 100644 --- a/src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs +++ b/src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs @@ -19,7 +19,8 @@ public async ValueTask AttackTargetAsync(Player player, IAttackable target, Skil { if (skill.Skill?.SkillType != SkillType.AreaSkillExplicitHits || !target.IsAlive - || target.IsAtSafezone()) + || target.IsAtSafezone() + || (target is Player && !player.GameContext.Configuration.AreaSkillHitsPlayer)) { return; } @@ -30,11 +31,6 @@ public async ValueTask AttackTargetAsync(Player player, IAttackable target, Skil // We don't log it as hacker attempt, since the AreaSkillAttackAction already does handle this. } - if (target is Player && !player.GameContext.Configuration.AreaSkillHitsPlayer) - { - return; - } - if (target.CheckSkillTargetRestrictions(player, skill.Skill)) { await target.AttackByAsync(player, skill, false).ConfigureAwait(false); diff --git a/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs b/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs index a725b3b98..c3f62ca6f 100644 --- a/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs +++ b/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs @@ -55,13 +55,13 @@ public override async ValueTask PerformSkillAsync(Player player, IAttackable tar if (attributes[Stats.IsStunned] > 0) { - player.Logger.LogWarning($"Probably Hacker - player {player} is attacking in stunned state"); + player.Logger.LogWarning("Probably Hacker - player {Player} is attacking in stunned state", player); return; } if (attributes[Stats.IsAsleep] > 0) { - player.Logger.LogWarning($"Probably Hacker - player {player} is attacking in asleep state"); + player.Logger.LogWarning("Probably Hacker - player {Player} is attacking in asleep state", player); return; } @@ -279,7 +279,7 @@ private async ValueTask ApplySkillAsync(Player player, IAttackable targete var canDoBuff = !player.IsAtSafezone() || player.CurrentMiniGame is { }; if (!canDoBuff) { - player.Logger.LogWarning($"Can't apply magic effect when being in the safezone. skill: {skill.Name} ({skill.Number}), skillType: {skill.SkillType}."); + player.Logger.LogWarning("Can't apply magic effect when being in the safe-zone. skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType); break; } @@ -295,12 +295,12 @@ private async ValueTask ApplySkillAsync(Player player, IAttackable targete } else { - player.Logger.LogWarning($"Skill.MagicEffectDef isn't null, but it's not a buff or regeneration skill. skill: {skill.Name} ({skill.Number}), skillType: {skill.SkillType}."); + player.Logger.LogWarning("Skill.MagicEffectDef isn't null, but it's not a buff or regeneration skill. skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType); } } else { - player.Logger.LogWarning($"Skill.MagicEffectDef is null, skill: {skill.Name} ({skill.Number}), skillType: {skill.SkillType}."); + player.Logger.LogWarning("Skill.MagicEffectDef is null, skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType); } } diff --git a/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs b/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs new file mode 100644 index 000000000..8045b4f0b --- /dev/null +++ b/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs @@ -0,0 +1,33 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Views.Party; +using MUnique.OpenMU.PlugIns; + +/// +/// Listens for the and automatically rejoins the player +/// to their previous party if it still exists when they enter the world. +/// +[PlugIn] +[Display( + Name = nameof(PlugInResources.PartyAutoRejoinPlugIn_Name), + Description = nameof(PlugInResources.PartyAutoRejoinPlugIn_Description), + ResourceType = typeof(PlugInResources))] +[Guid("013406B3-02AD-45EF-906B-177CBEC9B2D4")] +public sealed class PartyAutoRejoinPlugIn : IPlayerStateChangedPlugIn +{ + /// + public async ValueTask PlayerStateChangedAsync(Player player, State previousState, State currentState) + { + if (currentState != PlayerState.EnteredWorld) + { + return; + } + + await player.GameContext.PartyManager.OnMemberReconnectedAsync(player).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index e17680494..240eb82f9 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -3166,5 +3166,23 @@ public static string OfflineLevelingStopOnLoginPlugIn_Description { return ResourceManager.GetString("OfflineLevelingStopOnLoginPlugIn_Description", resourceCulture); } } + + /// + /// Looks up a localized string similar to Party auto rejoin. + /// + public static string PartyAutoRejoinPlugIn_Name { + get { + return ResourceManager.GetString("PartyAutoRejoinPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Party auto rejoin. + /// + public static string PartyAutoRejoinPlugIn_Description { + get { + return ResourceManager.GetString("PartyAutoRejoinPlugIn_Description", resourceCulture); + } + } } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 1c7601891..ba28c39f2 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1,4 +1,4 @@ - +