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),