diff --git a/docs/PlayerRefactoringPlan.md b/docs/PlayerRefactoringPlan.md index b10672f0d..5d123ee99 100644 --- a/docs/PlayerRefactoringPlan.md +++ b/docs/PlayerRefactoringPlan.md @@ -1,6 +1,6 @@ # Refactoring plan: breaking up the `Player` class -**Status:** in progress — phases 0b and 1 are done, see §6 +**Status:** in progress — phases 0b, 1, 2 and 3 are done, see §6 **Subject:** `src/GameLogic/Player.cs` (3,098 lines and ~150 members when this plan was written; 2,614 after phase 1) @@ -221,7 +221,7 @@ All of these live in `src/GameLogic/PlugIns/`, need a fresh `Guid` and a |---|---|---|---| | P3 | `IPlayerLeavingGamePlugIn` | `ValueTask PlayerLeavingGameAsync(Player player)` | `RemoveFromGameAsync` steps (1840-1856): party leave-temporarily, safezone move, temporary storage restore, `OpenedNpc` reset. **Only if** the `LogoutAction` state bug above is not fixed — with it fixed, `IPlayerStateChangedPlugIn` covers this too, and P3 is dropped | | P4 | `IPlayerSpawnGateSelectionPlugIn` | `ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args)` | the duel and guild-war-soccer branches of `GetSpawnGateOfCurrentMapAsync` (2409-2428); later also mini games and castle siege | -| P5 | `IExperienceCalculationPlugIn` | `ValueTask CalculateExperienceAsync(Player player, ExperienceCalculationArgs args)` | the multiplier chain in `CalculateExpAfterKill` (1271-1288): map multiplier, bonus rate, random min/max multipliers | +| P5 | `IExperienceCalculationPlugIn` | `ValueTask CalculateExperienceAsync(Player player, ExperienceCalculationArgs args)` | ✔ implemented, but *not* by splitting the multiplier chain of `CalculateExpAfterKill` into competing plugins: without plugin ordering (3.1a) their result would depend on the discovery order, and the random multiplier truncates to `int`, so it has to stay last. The rates and the map multiplier stay in the component, the plugin point runs after them and before the randomization, where factors compose order-independently | | P6 | `IPlayerGainedExperiencePlugIn` | `ValueTask PlayerGainedExperienceAsync(Player player, int experience, IAttackable? killedObject, ExperienceType type)` | `AddPetExperienceAsync` (2953-3020) becomes `PetExperiencePlugIn`; also the natural hook for statistics/events | | P7 | `ICharacterMasterLevelUpPlugIn` | `void CharacterMasterLeveledUp(Player player)` | mirrors the existing `ICharacterLevelUpPlugIn` for the master level branch (2099-2107) | | P8 | `IAttackerHitTargetPlugIn` | `ValueTask AttackerHitTargetAsync(IAttacker attacker, IAttackable target, HitInfo hitInfo, SkillEntry? skill)` | `AfterHitTargetAsync` (809-814): weapon durability, `HealthLossAfterHit`; plus mace mastery stun (797-800) | @@ -339,8 +339,8 @@ Each phase is independently mergeable and leaves the build green. | **0** | Characterization tests (see §7); plugin ordering (3.1a); per-player state bag (3.1b); args-object convention (3.1c) | low | 3,098 | | **0b** | ✔ done — State machine repair (§4.0): `ChangingMap` wired into warp/respawn, missing transitions added, `LogoutAction` transition fixed. Behavior change, own tests | medium | 2,614 | | **1** | ✔ done — §5.1 extension-method moves + nested class files | very low | 2,612 | -| **2** | `PlayerMovement`, `PlayerPersistence`, `PlayerSummon`, `PlayerStorages` components | low | ~2,150 | -| **3** | `PlayerExperience` + P5/P6/P7 + `PetExperiencePlugIn` | medium | ~1,850 | +| **2** | ✔ done — `PlayerMovement`, `PlayerPersistence`, `PlayerSummon`, `PlayerStorages` components | low | 2,269 | +| **3** | ✔ done — `PlayerExperience` + P5/P6/P7 + `PetExperiencePlugIn` | medium | 2,058 | | **4** | `PlayerMapTransitions` + P4 spawn gate plugins | medium | ~1,550 | | **5** | `PlayerCombat` + `AttackableNpcBase` dedup + P8 and the `IAttackableGotHit`/`GotKilled` plugins (PK state, respawn, reflection, durability) | **high** | ~1,150 | | **6** | Enter-world / leave-game moved onto `IPlayerStateChangedPlugIn` (+ P3 if needed) + `PlayerAttributeHost` + P9 regeneration | medium-high | ~900 | diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index ba583f184..6d9d6e3a6 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -46,7 +46,7 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke StopByDeath = false, }; - private readonly AsyncLock _experienceLock = new(); + private readonly PlayerExperience _experience; /// /// Serializes context mutations done by this player's action handlers against the periodic and @@ -97,6 +97,7 @@ public Player(IGameContext gameContext) this.Logger = gameContext.LoggerFactory.CreateLogger(); this.PersistenceContext = this.GameContext.PersistenceContextProvider.CreateNewPlayerContext(gameContext.Configuration); this._persistence = new PlayerPersistence(this); + this._experience = new PlayerExperience(this); this._movement = new PlayerMovement(this); this._summon = new PlayerSummon(this); this._storages = new PlayerStorages(this); @@ -996,101 +997,28 @@ public async ValueTask ClientReadyAfterMapChangeAsync() /// /// The killed object. /// The gained experience. - public async ValueTask AddExpAfterKillAsync(IAttackable killedObject) - { - if (this.SelectedCharacter?.CharacterClass is not { } characterClass) - { - return 0; - } - - var experience = this.CalculateExpAfterKill(killedObject); - if (experience == 0) - { - return 0; - } - - var currentLevel = (short)this.Attributes![Stats.Level]; - var isMaxLevel = currentLevel == this.GameContext.Configuration.MaximumLevel; - var isAddMasterExperience = characterClass.IsMasterClass && isMaxLevel; - - if (isAddMasterExperience) - { - await this.AddMasterExperienceAsync(experience, killedObject).ConfigureAwait(false); - } - else - { - await this.AddExperienceAsync(experience, killedObject).ConfigureAwait(false); - } - - await this.AddPetExperienceAsync(experience).ConfigureAwait(false); - - return experience; - } + public ValueTask AddExpAfterKillAsync(IAttackable killedObject) => this._experience.AddAfterKillAsync(killedObject); /// /// Calculates the amount of experience gained after a kill, without applying it to the character. /// /// The killed monster. /// The calculated experience amount. - public int CalculateExpAfterKill(IAttackable killedObject) - { - if (this.SelectedCharacter?.CharacterClass is not { } characterClass) - { - return 0; - } - - if (this.Attributes is not { } attributes) - { - return 0; - } - - var currentLevel = (short)attributes[Stats.Level]; - var isMaxLevel = currentLevel == this.GameContext.Configuration.MaximumLevel; - var isAddMasterExperience = characterClass.IsMasterClass && isMaxLevel; - var expRateAttribute = isAddMasterExperience ? Stats.MasterExperienceRate : Stats.ExperienceRate; - var gameRate = isAddMasterExperience ? this.GameContext.MasterExperienceRate : this.GameContext.ExperienceRate; - - var experience = killedObject.CalculateBaseExperience(attributes[Stats.TotalLevel]); - experience *= gameRate; - experience *= attributes[expRateAttribute] + attributes[Stats.BonusExperienceRate]; - experience *= this.CurrentMap?.Definition.ExpMultiplier ?? 1; - - var minMultiplier = attributes[Stats.RandomExperienceMinMultiplier]; - var maxMultiplier = attributes[Stats.RandomExperienceMaxMultiplier]; - if (minMultiplier > 0 && maxMultiplier > 0) - { - var minimumExperience = (int)(experience * minMultiplier); - var maximumExperience = (int)(experience * maxMultiplier); - if (minimumExperience < maximumExperience) - { - return Rand.NextInt(minimumExperience, maximumExperience); - } - } - - return (int)experience; - } + public ValueTask CalculateExpAfterKillAsync(IAttackable killedObject) => this._experience.CalculateAfterKillAsync(killedObject); /// /// Adds the master experience to the current character. /// /// The experience that should be added. /// The killed object that caused the experience gain. - public async ValueTask AddMasterExperienceAsync(int experience, IAttackable? killedObject) - { - using var d = await this._experienceLock.LockAsync().ConfigureAwait(false); - await this.AddMasterExperienceCoreAsync(experience, killedObject).ConfigureAwait(false); - } + public ValueTask AddMasterExperienceAsync(int experience, IAttackable? killedObject) => this._experience.AddMasterExperienceAsync(experience, killedObject); /// /// Adds the experience to the current character. /// /// The experience that should be added. /// The killed object that caused the experience gain. - public async ValueTask AddExperienceAsync(int experience, IAttackable? killedObject) - { - using var d = await this._experienceLock.LockAsync().ConfigureAwait(false); - await this.AddExperienceCoreAsync(experience, killedObject).ConfigureAwait(false); - } + public ValueTask AddExperienceAsync(int experience, IAttackable? killedObject) => this._experience.AddExperienceAsync(experience, killedObject); /// /// Moves the player to the specified coordinate. @@ -1490,95 +1418,6 @@ protected virtual ICustomPlugInContainer CreateViewPlugInContainer( throw new NotImplementedException("CreateViewPlugInContainer must be overwritten in derived classes."); } - private async ValueTask AddMasterExperienceCoreAsync(int experience, IAttackable? killedObject) - { - if (this.Attributes![Stats.MasterLevel] >= this.GameContext.Configuration.MaximumMasterLevel) - { - await this.InvokeViewPlugInAsync(p => p.AddExperienceAsync(0, killedObject, ExperienceType.MaxMasterLevelReached)).ConfigureAwait(false); - return; - } - - if (killedObject is not null && killedObject.Attributes[Stats.Level] < this.GameContext.Configuration.MinimumMonsterLevelForMasterExperience) - { - await this.InvokeViewPlugInAsync(p => p.AddExperienceAsync(0, killedObject, ExperienceType.MonsterLevelTooLowForMasterExperience)).ConfigureAwait(false); - return; - } - - long exp = experience; - - bool lvlup = false; - var expTable = this.GameContext.MasterExperienceTable; - if (expTable[(int)this.Attributes[Stats.MasterLevel] + 1] - this.SelectedCharacter!.MasterExperience < exp) - { - exp = expTable[(int)this.Attributes[Stats.MasterLevel] + 1] - this.SelectedCharacter.MasterExperience; - lvlup = true; - } - - this.SelectedCharacter.MasterExperience += exp; - - await this.InvokeViewPlugInAsync(p => p.AddExperienceAsync((int)exp, killedObject, ExperienceType.Master)).ConfigureAwait(false); - - if (lvlup) - { - this.Attributes[Stats.MasterLevel]++; - this.SelectedCharacter.MasterLevelUpPoints += (int)this.Attributes[Stats.MasterPointsPerLevelUp]; - this.SetReclaimableAttributesToMaximum(); - this.Logger.LogDebug("Character {0} leveled up to master level {1}", this.SelectedCharacter.Name, this.Attributes[Stats.MasterLevel]); - await this.InvokeViewPlugInAsync(p => p.UpdateMasterLevelAsync()).ConfigureAwait(false); - await this.ForEachWorldObserverAsync(p => p.ShowEffectAsync(this, IShowEffectPlugIn.EffectType.LevelUp), true).ConfigureAwait(false); - } - } - - private async ValueTask AddExperienceCoreAsync(int experience, IAttackable? killedObject) - { - var remainingExperience = experience; - while (remainingExperience > 0) - { - if (this.Attributes![Stats.Level] >= this.GameContext.Configuration.MaximumLevel) - { - await this.InvokeViewPlugInAsync(p => p.AddExperienceAsync(0, killedObject, ExperienceType.MaxLevelReached)).ConfigureAwait(false); - return; - } - - long gainedExperience = remainingExperience; - bool isLevelUp = false; - var expTable = this.GameContext.ExperienceTable; - var expForNextLevel = expTable[(int)this.Attributes[Stats.Level] + 1]; - if (expForNextLevel - this.SelectedCharacter!.Experience < gainedExperience) - { - gainedExperience = expForNextLevel - this.SelectedCharacter.Experience; - isLevelUp = true; - } - - this.SelectedCharacter.Experience += gainedExperience; - - await this.InvokeViewPlugInAsync(p => p.AddExperienceAsync((int)gainedExperience, killedObject, ExperienceType.Normal)).ConfigureAwait(false); - - if (!isLevelUp) - { - return; - } - - this.Attributes[Stats.Level]++; - this.SelectedCharacter.LevelUpPoints += (int)this.Attributes[Stats.PointsPerLevelUp]; - this.SetReclaimableAttributesToMaximum(); - this.Logger.LogDebug("Character {0} leveled up to {1}", this.SelectedCharacter.Name, this.Attributes[Stats.Level]); - - this.GameContext.PlugInManager.GetPlugInPoint()?.CharacterLeveledUp(this); - - await this.InvokeViewPlugInAsync(p => p.UpdateLevelAsync()).ConfigureAwait(false); - await this.ForEachWorldObserverAsync(p => p.ShowEffectAsync(this, IShowEffectPlugIn.EffectType.LevelUp), true).ConfigureAwait(false); - - remainingExperience -= (int)gainedExperience; - if (remainingExperience <= 0 - || this.Attributes[Stats.Level] >= this.GameContext.Configuration.MaximumLevel - || this.GameContext.Configuration.PreventExperienceOverflow) - { - return; - } - } - } - /// /// Handles the move to next safezone logic after death or disconnect. /// @@ -2060,7 +1899,10 @@ private void SetReclaimableAttributesBeforeEnterGame() this.Attributes[Stats.CurrentHealth] = Math.Min(this.Attributes[Stats.CurrentHealth], this.Attributes[Stats.MaximumHealth]); } - private void SetReclaimableAttributesToMaximum() + /// + /// Sets the current values of the regeneration attributes to their maximum values. + /// + internal void SetReclaimableAttributesToMaximum() { foreach (var regeneration in Stats.IntervalRegenerationAttributes) { @@ -2204,75 +2046,6 @@ private async ValueTask DecreaseWeaponDurabilityAfterHitAsync() } } - private async ValueTask AddPetExperienceAsync(double gainedExperience) - { - async ValueTask AddExpToPetAsync(Item pet, double experience) - { - pet.PetExperience += (int)experience; - - while (pet.PetExperience >= pet.Definition!.GetExperienceOfPetLevel((byte)(pet.Level + 1), pet.Definition!.MaximumItemLevel) - && (!pet.IsDarkRaven() || pet.GetDarkRavenLeadershipRequirement(pet.Level + 1) <= this.Attributes![Stats.TotalLeadership])) - { - pet.Level++; - this.Attributes!.ItemPowerUps[pet] = this.Attributes.ItemPowerUps[pet] - .Append(new PowerUpWrapper( - new SimpleElement(1, AggregateType.AddRaw), - pet.IsDarkRaven() ? Stats.RavenLevel : Stats.HorseLevel, - this.Attributes)).ToList(); - - await this.InvokeViewPlugInAsync(p => p.ShowPetInfoAsync(pet, pet.ItemSlot, PetStorageLocation.InventoryPetSlot)).ConfigureAwait(false); - } - } - - Item? GetTrainablePet(byte inventorySlot) - { - var pet = this.Inventory?.GetItem(inventorySlot); - if (pet is not null - && pet.Definition is not null - && pet.Definition.PetExperienceFormula is not null - && pet.Definition.MaximumItemLevel > 0 - && pet.Durability > 0 - && pet.Level < pet.Definition.MaximumItemLevel) - { - return pet; - } - - return null; - } - - const double petShare = 0.2; - Item? movePet = GetTrainablePet(InventoryConstants.PetSlot); - Item? attackPet = GetTrainablePet(InventoryConstants.RightHandSlot); - - if (movePet is null && attackPet is null) - { - return; - } - - var petExperience = (int)(gainedExperience * petShare); - - if (movePet is not null && attackPet is not null) - { - // Both are there, so each gains just the half. - petExperience /= 2; - } - - if (petExperience < 1) - { - return; - } - - if (movePet is { }) - { - await AddExpToPetAsync(movePet, petExperience).ConfigureAwait(false); - } - - if (attackPet is { }) - { - await AddExpToPetAsync(attackPet, petExperience).ConfigureAwait(false); - } - } - private async ValueTask CloseTradeIfNeededAsync() { if (this.PlayerState.CurrentState == GameLogic.PlayerState.TradeButtonPressed diff --git a/src/GameLogic/PlayerExperience.cs b/src/GameLogic/PlayerExperience.cs new file mode 100644 index 000000000..7ea5b3d04 --- /dev/null +++ b/src/GameLogic/PlayerExperience.cs @@ -0,0 +1,247 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.Views.Character; +using MUnique.OpenMU.GameLogic.Views.World; +using Nito.AsyncEx; + +/// +/// The experience and the leveling of a . +/// +internal sealed class PlayerExperience +{ + private readonly Player _player; + + private readonly AsyncLock _lock = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public PlayerExperience(Player player) + { + this._player = player; + } + + /// + /// Adds experience points after killing the target object. + /// + /// The killed object. + /// The gained experience. + public async ValueTask AddAfterKillAsync(IAttackable killedObject) + { + if (!this.TryGetExperienceKind(out var isMasterExperience)) + { + return 0; + } + + var experience = await this.CalculateAfterKillAsync(killedObject).ConfigureAwait(false); + if (experience == 0) + { + return 0; + } + + if (isMasterExperience) + { + await this.AddMasterExperienceAsync(experience, killedObject).ConfigureAwait(false); + } + else + { + await this.AddExperienceAsync(experience, killedObject).ConfigureAwait(false); + } + + if (this._player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + await plugInPoint.PlayerGainedExperienceAsync(this._player, experience, killedObject, isMasterExperience).ConfigureAwait(false); + } + + return experience; + } + + /// + /// Calculates the amount of experience gained after a kill, without applying it to the character. + /// + /// The killed monster. + /// The calculated experience amount. + public async ValueTask CalculateAfterKillAsync(IAttackable killedObject) + { + if (!this.TryGetExperienceKind(out var isMasterExperience) + || this._player.Attributes is not { } attributes) + { + return 0; + } + + var expRateAttribute = isMasterExperience ? Stats.MasterExperienceRate : Stats.ExperienceRate; + var gameRate = isMasterExperience ? this._player.GameContext.MasterExperienceRate : this._player.GameContext.ExperienceRate; + + var experience = killedObject.CalculateBaseExperience(attributes[Stats.TotalLevel]); + experience *= gameRate; + experience *= attributes[expRateAttribute] + attributes[Stats.BonusExperienceRate]; + experience *= this._player.CurrentMap?.Definition.ExpMultiplier ?? 1; + + if (this._player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + var args = new ExperienceCalculationArgs(killedObject, isMasterExperience, experience); + await plugInPoint.CalculateExperienceAsync(this._player, args).ConfigureAwait(false); + experience = args.Experience; + } + + var minMultiplier = attributes[Stats.RandomExperienceMinMultiplier]; + var maxMultiplier = attributes[Stats.RandomExperienceMaxMultiplier]; + if (minMultiplier > 0 && maxMultiplier > 0) + { + var minimumExperience = (int)(experience * minMultiplier); + var maximumExperience = (int)(experience * maxMultiplier); + if (minimumExperience < maximumExperience) + { + return Rand.NextInt(minimumExperience, maximumExperience); + } + } + + return (int)experience; + } + + /// + /// Adds the master experience to the current character. + /// + /// The experience that should be added. + /// The killed object that caused the experience gain. + public async ValueTask AddMasterExperienceAsync(int experience, IAttackable? killedObject) + { + using var d = await this._lock.LockAsync().ConfigureAwait(false); + await this.AddMasterExperienceCoreAsync(experience, killedObject).ConfigureAwait(false); + } + + /// + /// Adds the experience to the current character. + /// + /// The experience that should be added. + /// The killed object that caused the experience gain. + public async ValueTask AddExperienceAsync(int experience, IAttackable? killedObject) + { + using var d = await this._lock.LockAsync().ConfigureAwait(false); + await this.AddExperienceCoreAsync(experience, killedObject).ConfigureAwait(false); + } + + /// + /// Determines whether the player gains master experience instead of normal experience. + /// + /// If set to true, master experience is gained. + /// True, if the player can gain experience at all; Otherwise, false. + private bool TryGetExperienceKind(out bool isMasterExperience) + { + isMasterExperience = false; + if (this._player.SelectedCharacter?.CharacterClass is not { } characterClass + || this._player.Attributes is not { } attributes) + { + return false; + } + + var currentLevel = (short)attributes[Stats.Level]; + var isMaxLevel = currentLevel == this._player.GameContext.Configuration.MaximumLevel; + isMasterExperience = characterClass.IsMasterClass && isMaxLevel; + return true; + } + + private async ValueTask AddMasterExperienceCoreAsync(int experience, IAttackable? killedObject) + { + var player = this._player; + if (player.Attributes![Stats.MasterLevel] >= player.GameContext.Configuration.MaximumMasterLevel) + { + await player.InvokeViewPlugInAsync(p => p.AddExperienceAsync(0, killedObject, ExperienceType.MaxMasterLevelReached)).ConfigureAwait(false); + return; + } + + if (killedObject is not null && killedObject.Attributes[Stats.Level] < player.GameContext.Configuration.MinimumMonsterLevelForMasterExperience) + { + await player.InvokeViewPlugInAsync(p => p.AddExperienceAsync(0, killedObject, ExperienceType.MonsterLevelTooLowForMasterExperience)).ConfigureAwait(false); + return; + } + + long exp = experience; + + bool lvlup = false; + var expTable = player.GameContext.MasterExperienceTable; + if (expTable[(int)player.Attributes[Stats.MasterLevel] + 1] - player.SelectedCharacter!.MasterExperience < exp) + { + exp = expTable[(int)player.Attributes[Stats.MasterLevel] + 1] - player.SelectedCharacter.MasterExperience; + lvlup = true; + } + + player.SelectedCharacter.MasterExperience += exp; + + await player.InvokeViewPlugInAsync(p => p.AddExperienceAsync((int)exp, killedObject, ExperienceType.Master)).ConfigureAwait(false); + + if (lvlup) + { + player.Attributes[Stats.MasterLevel]++; + player.SelectedCharacter.MasterLevelUpPoints += (int)player.Attributes[Stats.MasterPointsPerLevelUp]; + player.SetReclaimableAttributesToMaximum(); + player.Logger.LogDebug("Character {0} leveled up to master level {1}", player.SelectedCharacter.Name, player.Attributes[Stats.MasterLevel]); + + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + await plugInPoint.CharacterMasterLeveledUpAsync(player).ConfigureAwait(false); + } + + await player.InvokeViewPlugInAsync(p => p.UpdateMasterLevelAsync()).ConfigureAwait(false); + await player.ForEachWorldObserverAsync(p => p.ShowEffectAsync(player, IShowEffectPlugIn.EffectType.LevelUp), true).ConfigureAwait(false); + } + } + + private async ValueTask AddExperienceCoreAsync(int experience, IAttackable? killedObject) + { + var player = this._player; + var remainingExperience = experience; + while (remainingExperience > 0) + { + if (player.Attributes![Stats.Level] >= player.GameContext.Configuration.MaximumLevel) + { + await player.InvokeViewPlugInAsync(p => p.AddExperienceAsync(0, killedObject, ExperienceType.MaxLevelReached)).ConfigureAwait(false); + return; + } + + long gainedExperience = remainingExperience; + bool isLevelUp = false; + var expTable = player.GameContext.ExperienceTable; + var expForNextLevel = expTable[(int)player.Attributes[Stats.Level] + 1]; + if (expForNextLevel - player.SelectedCharacter!.Experience < gainedExperience) + { + gainedExperience = expForNextLevel - player.SelectedCharacter.Experience; + isLevelUp = true; + } + + player.SelectedCharacter.Experience += gainedExperience; + + await player.InvokeViewPlugInAsync(p => p.AddExperienceAsync((int)gainedExperience, killedObject, ExperienceType.Normal)).ConfigureAwait(false); + + if (!isLevelUp) + { + return; + } + + player.Attributes[Stats.Level]++; + player.SelectedCharacter.LevelUpPoints += (int)player.Attributes[Stats.PointsPerLevelUp]; + player.SetReclaimableAttributesToMaximum(); + player.Logger.LogDebug("Character {0} leveled up to {1}", player.SelectedCharacter.Name, player.Attributes[Stats.Level]); + + player.GameContext.PlugInManager.GetPlugInPoint()?.CharacterLeveledUp(player); + + await player.InvokeViewPlugInAsync(p => p.UpdateLevelAsync()).ConfigureAwait(false); + await player.ForEachWorldObserverAsync(p => p.ShowEffectAsync(player, IShowEffectPlugIn.EffectType.LevelUp), true).ConfigureAwait(false); + + remainingExperience -= (int)gainedExperience; + if (remainingExperience <= 0 + || player.Attributes[Stats.Level] >= player.GameContext.Configuration.MaximumLevel + || player.GameContext.Configuration.PreventExperienceOverflow) + { + return; + } + } + } +} diff --git a/src/GameLogic/PlugIns/ExperienceCalculationArgs.cs b/src/GameLogic/PlugIns/ExperienceCalculationArgs.cs new file mode 100644 index 000000000..3f14262bb --- /dev/null +++ b/src/GameLogic/PlugIns/ExperienceCalculationArgs.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +/// +/// The arguments of the . Plugin points can't return +/// values, so the calculated experience is passed in and out through this object. +/// +public sealed class ExperienceCalculationArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The killed object which caused the experience gain. + /// If set to true, master experience is gained. + /// The calculated experience. + public ExperienceCalculationArgs(IAttackable killedObject, bool isMasterExperience, double experience) + { + this.KilledObject = killedObject; + this.IsMasterExperience = isMasterExperience; + this.Experience = experience; + } + + /// + /// Gets the killed object which caused the experience gain. + /// + public IAttackable KilledObject { get; } + + /// + /// Gets a value indicating whether master experience is gained instead of normal experience. + /// + public bool IsMasterExperience { get; } + + /// + /// Gets or sets the calculated experience. It can be modified by the plugins. + /// + public double Experience { get; set; } +} diff --git a/src/GameLogic/PlugIns/ICharacterMasterLevelUpPlugIn.cs b/src/GameLogic/PlugIns/ICharacterMasterLevelUpPlugIn.cs new file mode 100644 index 000000000..6a63174a8 --- /dev/null +++ b/src/GameLogic/PlugIns/ICharacterMasterLevelUpPlugIn.cs @@ -0,0 +1,25 @@ +// +// 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.PlugIns; + +/// +/// A plugin interface which is called when a gained a master level. +/// +/// +/// It's the counterpart of for the master level. +/// +[Guid("4F63B5A9-2E4E-4F0F-8A6B-19B7A9E1D4C3")] +[PlugInPoint("Player gained master level", "Plugins which will be executed when a player gained a master level.")] +public interface ICharacterMasterLevelUpPlugIn +{ + /// + /// This method is called when a gained a master level. + /// + /// The player. + ValueTask CharacterMasterLeveledUpAsync(Player player); +} diff --git a/src/GameLogic/PlugIns/IExperienceCalculationPlugIn.cs b/src/GameLogic/PlugIns/IExperienceCalculationPlugIn.cs new file mode 100644 index 000000000..522149caf --- /dev/null +++ b/src/GameLogic/PlugIns/IExperienceCalculationPlugIn.cs @@ -0,0 +1,31 @@ +// +// 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.PlugIns; + +/// +/// A plugin interface which is called when the experience for a kill is calculated, so that the +/// amount can be modified, e.g. by an event or a party bonus. +/// +/// +/// The plugins are called after the configured experience rates and the map multiplier have been +/// applied, and before the random multiplier of +/// and is applied. +/// Because the plugins are not called in a defined order, they should only apply factors or +/// summands which are independent of each other. +/// +[Guid("35B0F1F6-EE79-4C0E-9C5C-6A9E0F8DCB70")] +[PlugInPoint("Experience calculation", "Plugins which modify the amount of experience which a player gains for a kill.")] +public interface IExperienceCalculationPlugIn +{ + /// + /// Is called when the experience for a kill has been calculated. + /// + /// The player which gains the experience. + /// The arguments, which hold the calculated experience. + ValueTask CalculateExperienceAsync(Player player, ExperienceCalculationArgs args); +} diff --git a/src/GameLogic/PlugIns/IPlayerGainedExperiencePlugIn.cs b/src/GameLogic/PlugIns/IPlayerGainedExperiencePlugIn.cs new file mode 100644 index 000000000..0a64d7cdb --- /dev/null +++ b/src/GameLogic/PlugIns/IPlayerGainedExperiencePlugIn.cs @@ -0,0 +1,25 @@ +// +// 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.PlugIns; + +/// +/// A plugin interface which is called after a player gained experience for a kill. +/// +[Guid("D5A0F2F1-3C1E-4E60-9E8B-59A6EF8A0C21")] +[PlugInPoint("Player gained experience", "Plugins which are executed after a player gained experience for a kill.")] +public interface IPlayerGainedExperiencePlugIn +{ + /// + /// Is called after the player gained experience for a kill. + /// + /// The player which gained the experience. + /// The gained experience. + /// The killed object which caused the experience gain. + /// If set to true, master experience was gained. + ValueTask PlayerGainedExperienceAsync(Player player, int experience, IAttackable killedObject, bool isMasterExperience); +} diff --git a/src/GameLogic/PlugIns/PetExperiencePlugIn.cs b/src/GameLogic/PlugIns/PetExperiencePlugIn.cs new file mode 100644 index 000000000..3554fc2f4 --- /dev/null +++ b/src/GameLogic/PlugIns/PetExperiencePlugIn.cs @@ -0,0 +1,98 @@ +// +// 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.AttributeSystem; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Pet; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; +using MUnique.OpenMU.GameLogic.Views.Pet; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin which gives the trainable pets of a player a share of the experience which the +/// player gained for a kill, and levels them up. +/// +[PlugIn] +[Display(Name = nameof(PetExperiencePlugIn), Description = "Gives the trainable pets of a player a share of the experience which the player gained for a kill.")] +[Guid("6B9A0A0E-5C6A-4E8F-A4E1-3C7B8B9A2D51")] +public class PetExperiencePlugIn : IPlayerGainedExperiencePlugIn +{ + /// + /// The share of the player's experience which a pet gains. If both, a riding pet and an + /// attacking pet are equipped, each of them gains the half of it. + /// + private const double PetShare = 0.2; + + /// + public async ValueTask PlayerGainedExperienceAsync(Player player, int experience, IAttackable killedObject, bool isMasterExperience) + { + var movePet = GetTrainablePet(player, InventoryConstants.PetSlot); + var attackPet = GetTrainablePet(player, InventoryConstants.RightHandSlot); + + if (movePet is null && attackPet is null) + { + return; + } + + var petExperience = (int)(experience * PetShare); + + if (movePet is not null && attackPet is not null) + { + // Both are there, so each gains just the half. + petExperience /= 2; + } + + if (petExperience < 1) + { + return; + } + + if (movePet is { }) + { + await AddExpToPetAsync(player, movePet, petExperience).ConfigureAwait(false); + } + + if (attackPet is { }) + { + await AddExpToPetAsync(player, attackPet, petExperience).ConfigureAwait(false); + } + } + + private static Item? GetTrainablePet(Player player, byte inventorySlot) + { + var pet = player.Inventory?.GetItem(inventorySlot); + if (pet is not null + && pet.Definition is not null + && pet.Definition.PetExperienceFormula is not null + && pet.Definition.MaximumItemLevel > 0 + && pet.Durability > 0 + && pet.Level < pet.Definition.MaximumItemLevel) + { + return pet; + } + + return null; + } + + private static async ValueTask AddExpToPetAsync(Player player, Item pet, double experience) + { + pet.PetExperience += (int)experience; + + while (pet.PetExperience >= pet.Definition!.GetExperienceOfPetLevel((byte)(pet.Level + 1), pet.Definition!.MaximumItemLevel) + && (!pet.IsDarkRaven() || pet.GetDarkRavenLeadershipRequirement(pet.Level + 1) <= player.Attributes![Stats.TotalLeadership])) + { + pet.Level++; + player.Attributes!.ItemPowerUps[pet] = player.Attributes.ItemPowerUps[pet] + .Append(new PowerUpWrapper( + new SimpleElement(1, AggregateType.AddRaw), + pet.IsDarkRaven() ? Stats.RavenLevel : Stats.HorseLevel, + player.Attributes)).ToList(); + + await player.InvokeViewPlugInAsync(p => p.ShowPetInfoAsync(pet, pet.ItemSlot, PetStorageLocation.InventoryPetSlot)).ConfigureAwait(false); + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs b/tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs new file mode 100644 index 000000000..93fe347be --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs @@ -0,0 +1,198 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.Persistence.InMemory; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests for the plugin points around the experience gain of a player. +/// +[TestFixture] +public class ExperiencePlugInTests +{ + /// + /// Tests that a plugin can modify the experience which is gained for a kill. + /// + [Test] + public async ValueTask CalculationPlugInModifiesGainedExperienceAsync() + { + var player = await this.CreatePlayerAsync().ConfigureAwait(false); + var killedObject = CreateKilledObject(level: 100); + + var withoutPlugIn = await player.CalculateExpAfterKillAsync(killedObject).ConfigureAwait(false); + Assert.That(withoutPlugIn, Is.GreaterThan(0)); + + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(new DoubleExperiencePlugIn()); + var withPlugIn = await player.CalculateExpAfterKillAsync(killedObject).ConfigureAwait(false); + + Assert.That(withPlugIn, Is.EqualTo(withoutPlugIn * 2)); + } + + /// + /// Tests that the calculation plugin also affects the experience which is actually granted. + /// + [Test] + public async ValueTask CalculationPlugInAffectsGrantedExperienceAsync() + { + var player = await this.CreatePlayerAsync().ConfigureAwait(false); + var killedObject = CreateKilledObject(level: 100); + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(new DoubleExperiencePlugIn()); + + var gained = await player.AddExpAfterKillAsync(killedObject).ConfigureAwait(false); + + Assert.That(gained, Is.GreaterThan(0)); + Assert.That(player.SelectedCharacter!.Experience, Is.EqualTo(gained)); + } + + /// + /// Tests that a plugin is informed about the gained experience, which is how the pets get + /// their share of it. + /// + [Test] + public async ValueTask GainedExperienceIsReportedToPlugInAsync() + { + var player = await this.CreatePlayerAsync().ConfigureAwait(false); + var plugIn = new ExperienceRecordingPlugIn(); + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(plugIn); + var killedObject = CreateKilledObject(level: 100); + + var gained = await player.AddExpAfterKillAsync(killedObject).ConfigureAwait(false); + + Assert.That(plugIn.Gains, Has.Count.EqualTo(1)); + Assert.That(plugIn.Gains[0].Experience, Is.EqualTo(gained)); + Assert.That(plugIn.Gains[0].IsMasterExperience, Is.False); + Assert.That(plugIn.Gains[0].KilledObject, Is.SameAs(killedObject)); + } + + /// + /// Tests that no plugin is called when the player can't gain any experience. + /// + [Test] + public async ValueTask GainedExperienceIsNotReportedWithoutExperienceAsync() + { + var player = await this.CreatePlayerAsync(maximumLevel: 1).ConfigureAwait(false); + var plugIn = new ExperienceRecordingPlugIn(); + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(plugIn); + + await player.AddExpAfterKillAsync(CreateKilledObject(level: 0)).ConfigureAwait(false); + + Assert.That(plugIn.Gains, Is.Empty); + } + + /// + /// Tests that a master level up is reported to the corresponding plugin point. + /// + [Test] + public async ValueTask MasterLevelUpIsReportedToPlugInAsync() + { + var player = await this.CreatePlayerAsync(maximumLevel: 10, isMasterClass: true, level: 10).ConfigureAwait(false); + var plugIn = new MasterLevelUpRecordingPlugIn(); + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(plugIn); + // The level up happens when the required experience is exceeded, so one more point is needed. + var requiredExperience = player.GameContext.MasterExperienceTable[1] + 1; + + await player.AddMasterExperienceAsync((int)requiredExperience, null).ConfigureAwait(false); + + Assert.That((int)player.Attributes![Stats.MasterLevel], Is.EqualTo(1)); + Assert.That(player.SelectedCharacter!.MasterExperience, Is.EqualTo(player.GameContext.MasterExperienceTable[1])); + Assert.That(plugIn.LevelUpCount, Is.EqualTo(1)); + } + + private static Mock CreateKilledObjectMock(float level) + { + var attributes = new Mock(); + attributes.Setup(a => a[Stats.Level]).Returns(level); + + var result = new Mock(); + result.SetupGet(a => a.Attributes).Returns(attributes.Object); + result.SetupGet(a => a.CurrentMap).Returns((GameMap?)null); + return result; + } + + private static IAttackable CreateKilledObject(float level) => CreateKilledObjectMock(level).Object; + + private async ValueTask CreatePlayerAsync(short maximumLevel = 100, bool isMasterClass = false, short level = 1) + { + var contextProvider = new InMemoryPersistenceContextProvider(); + var gameConfiguration = contextProvider.CreateNewContext().CreateNew(); + gameConfiguration.RecoveryInterval = int.MaxValue; + gameConfiguration.MaximumLevel = maximumLevel; + gameConfiguration.MaximumMasterLevel = 200; + gameConfiguration.MinimumMonsterLevelForMasterExperience = 0; + gameConfiguration.ExperienceRate = 1.0f; + gameConfiguration.MasterExperienceRate = 1.0f; + var map = contextProvider.CreateNewContext().CreateNew(); + map.ExpMultiplier = 1.0f; + gameConfiguration.Maps.Add(map); + + var mapInitializer = new MapInitializer(gameConfiguration, new NullLogger(), NullDropGenerator.Instance, null); + var gameContext = new GameContext( + gameConfiguration, + contextProvider, + mapInitializer, + new NullLoggerFactory(), + new PlugInManager(new List(), new NullLoggerFactory(), null, null), + NullDropGenerator.Instance, + new ConfigurationChangeMediator()); + mapInitializer.PlugInManager = gameContext.PlugInManager; + mapInitializer.PathFinderPool = gameContext.PathFinderPool; + + var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + player.SelectedCharacter!.CharacterClass!.IsMasterClass = isMasterClass; + player.Attributes![Stats.Level] = level; + player.Attributes[Stats.MasterLevel] = 0; + player.Attributes[Stats.PointsPerLevelUp] = 1; + player.Attributes[Stats.MasterPointsPerLevelUp] = 1; + player.Attributes.AddElement(new SimpleElement(1.0f, AggregateType.AddRaw), Stats.ExperienceRate); + player.Attributes.AddElement(new SimpleElement(1.0f, AggregateType.AddRaw), Stats.MasterExperienceRate); + player.Attributes.AddElement(new SimpleElement(level, AggregateType.AddRaw), Stats.TotalLevel); + player.SelectedCharacter.Experience = 0; + player.SelectedCharacter.MasterExperience = 0; + return player; + } + + [Guid("A0C4C8E1-2D2E-4F1A-9C51-0B8E1E2A5D71")] + private sealed class DoubleExperiencePlugIn : IExperienceCalculationPlugIn + { + public ValueTask CalculateExperienceAsync(Player player, ExperienceCalculationArgs args) + { + args.Experience *= 2; + return ValueTask.CompletedTask; + } + } + + [Guid("B31E7E52-6F3B-4E0C-9C8A-8F0A2C4D6E13")] + private sealed class ExperienceRecordingPlugIn : IPlayerGainedExperiencePlugIn + { + public List<(int Experience, IAttackable KilledObject, bool IsMasterExperience)> Gains { get; } = new(); + + public ValueTask PlayerGainedExperienceAsync(Player player, int experience, IAttackable killedObject, bool isMasterExperience) + { + this.Gains.Add((experience, killedObject, isMasterExperience)); + return ValueTask.CompletedTask; + } + } + + [Guid("C2F5A9D3-8B1E-4A7C-B0D2-5E6F1A3C8B24")] + private sealed class MasterLevelUpRecordingPlugIn : ICharacterMasterLevelUpPlugIn + { + public int LevelUpCount { get; private set; } + + public ValueTask CharacterMasterLeveledUpAsync(Player player) + { + this.LevelUpCount++; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs b/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs index 3755f77a0..301ca0074 100644 --- a/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs +++ b/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs @@ -80,6 +80,7 @@ public static async ValueTask CreatePlayerAsync(IGameContext gameContext new List { new (Stats.Level, 0, false), + new (Stats.MasterLevel, 0, false), new (Stats.BaseStrength, 28, true), new (Stats.BaseAgility, 20, true), new (Stats.BaseVitality, 25, true),