From c44a6d138c885110d7d709a4b0d6c684be3e26e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:59:23 +0000 Subject: [PATCH 1/2] Extract the experience of the player and make it extensible Moves the experience and leveling into a PlayerExperience component and introduces the plugin points around it: - IExperienceCalculationPlugIn can modify the amount of experience which is gained for a kill. It's called after the configured rates and the map multiplier have been applied and before the random multiplier, where factors compose independently of the plugin order. - IPlayerGainedExperiencePlugIn is called after the experience was added. The pet experience, which was hardcoded in the player, is now the PetExperiencePlugIn on this point. - ICharacterMasterLevelUpPlugIn is the counterpart of the existing ICharacterLevelUpPlugIn for the master level, which had no plugin point at all. CalculateExpAfterKill becomes CalculateExpAfterKillAsync, because the calculation calls a plugin point now. It had no callers outside of the player. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V5grV5oveZNhhjK1mazM2B --- docs/PlayerRefactoringPlan.md | 8 +- src/GameLogic/Player.cs | 247 +----------------- src/GameLogic/PlayerExperience.cs | 247 ++++++++++++++++++ .../PlugIns/ExperienceCalculationArgs.cs | 40 +++ .../PlugIns/ICharacterMasterLevelUpPlugIn.cs | 25 ++ .../PlugIns/IExperienceCalculationPlugIn.cs | 31 +++ .../PlugIns/IPlayerGainedExperiencePlugIn.cs | 25 ++ src/GameLogic/PlugIns/PetExperiencePlugIn.cs | 98 +++++++ .../ExperiencePlugInTests.cs | 197 ++++++++++++++ 9 files changed, 677 insertions(+), 241 deletions(-) create mode 100644 src/GameLogic/PlayerExperience.cs create mode 100644 src/GameLogic/PlugIns/ExperienceCalculationArgs.cs create mode 100644 src/GameLogic/PlugIns/ICharacterMasterLevelUpPlugIn.cs create mode 100644 src/GameLogic/PlugIns/IExperienceCalculationPlugIn.cs create mode 100644 src/GameLogic/PlugIns/IPlayerGainedExperiencePlugIn.cs create mode 100644 src/GameLogic/PlugIns/PetExperiencePlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs 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..a5c52b3ad --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs @@ -0,0 +1,197 @@ +// +// 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(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; + } + } +} From 348ab8733d1b90d8696b7410f269296c5c2e038d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 13:24:24 +0000 Subject: [PATCH 2/2] Add the master level to the character class of the test player The mock character class of the PlayerTestHelper didn't define the master level as stat attribute, unlike every real character class, so a master level up didn't stick and couldn't be asserted in tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V5grV5oveZNhhjK1mazM2B --- tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs | 1 + tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs b/tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs index a5c52b3ad..93fe347be 100644 --- a/tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs +++ b/tests/MUnique.OpenMU.Tests/ExperiencePlugInTests.cs @@ -104,6 +104,7 @@ public async ValueTask MasterLevelUpIsReportedToPlugInAsync() 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)); } 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),