From bd23f2055f47a469351854825674458cc82501e5 Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Fri, 17 Apr 2026 17:04:29 -0300
Subject: [PATCH 1/2] feature: implement party xp bonus
---
src/GameLogic/IPartyMember.cs | 5 ++
src/GameLogic/OfflinePartyMember.cs | 4 +
src/GameLogic/Party.cs | 126 ++++++++++++++++++++--------
src/GameLogic/Player.cs | 15 ++--
4 files changed, 110 insertions(+), 40 deletions(-)
diff --git a/src/GameLogic/IPartyMember.cs b/src/GameLogic/IPartyMember.cs
index 1a01b7bf9..04cdbbdba 100644
--- a/src/GameLogic/IPartyMember.cs
+++ b/src/GameLogic/IPartyMember.cs
@@ -19,6 +19,11 @@ public interface IPartyMember : IWorldObserver, IObservable, IIdentifiable, ILoc
///
IPartyMember? LastPartyRequester { get; set; }
+ ///
+ /// Gets the character class.
+ ///
+ CharacterClass? CharacterClass { get; }
+
///
/// Gets the maximum health.
///
diff --git a/src/GameLogic/OfflinePartyMember.cs b/src/GameLogic/OfflinePartyMember.cs
index 7841f1c84..948bb9a80 100644
--- a/src/GameLogic/OfflinePartyMember.cs
+++ b/src/GameLogic/OfflinePartyMember.cs
@@ -27,6 +27,7 @@ public OfflinePartyMember(IPartyMember player)
this.MaximumHealth = player.MaximumHealth;
this.CurrentMap = player.CurrentMap;
this.Position = player.Position;
+ this.CharacterClass = player.CharacterClass;
this.Logger = player?.Logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
}
@@ -36,6 +37,9 @@ public OfflinePartyMember(IPartyMember player)
///
public IPartyMember? LastPartyRequester { get; set; }
+ ///
+ public CharacterClass? CharacterClass { get; }
+
///
public uint MaximumHealth { get; }
diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs
index 00a77f952..5b0daa9b6 100644
--- a/src/GameLogic/Party.cs
+++ b/src/GameLogic/Party.cs
@@ -34,6 +34,7 @@ public sealed class Party : AsyncDisposable
private CancellationTokenSource? _healthUpdateCts;
private IPartyMember[] _partyMembers = [];
+ private double _experienceBonus = 1.0;
///
/// Initializes a new instance of the class.
@@ -92,6 +93,7 @@ public async ValueTask AddAsync(IPartyMember newMember)
newMember.Party = this;
this._partyManager.TrackMembership(newMember.Name, this);
this._partyMembers = [..this._partyMembers, newMember];
+ this.UpdateExperienceBonus();
}
await this.SendPartyListAsync().ConfigureAwait(false);
@@ -135,6 +137,7 @@ public async ValueTask ReplaceMemberAsync(IPartyMember oldMember, IPartyMember n
}
this._partyMembers = updated;
+ this.UpdateExperienceBonus();
}
await this.SendPartyListAsync().ConfigureAwait(false);
@@ -340,22 +343,6 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}
- private static (int Total, float PerLevel) CalculatePartyExperience(List recipients, IAttackable killed)
- {
- var count = recipients.Count;
- var totalLevel = recipients.Sum(p => (int)p.Attributes![Stats.TotalLevel]);
- var averageLevel = totalLevel / count;
- var baseExp = killed.CalculateBaseExperience(averageLevel);
-
- var totalAvg = baseExp * count * Math.Pow(1.05, count - 1);
- totalAvg *= killed.CurrentMap?.Definition.ExpMultiplier ?? 1;
-
- var total = Rand.NextInt((int)(totalAvg * 0.8), (int)(totalAvg * 1.2));
- var perLevel = (float)total / totalLevel;
-
- return (total, perLevel);
- }
-
private static async ValueTask AwardExperienceAsync(Player player, float perLevel, IAttackable killed)
{
var attributes = player.Attributes!;
@@ -403,6 +390,8 @@ private async ValueTask ExitPartyAsync(IPartyMember member, byte index)
{
this._partyMembers = this._partyMembers.Where(m => m != member).ToArray();
}
+
+ this.UpdateExperienceBonus();
}
if (shouldDispose)
@@ -439,6 +428,27 @@ private void CleanupMember(IPartyMember member)
}
}
+ private async ValueTask UpdateNearbyCountAsync()
+ {
+ foreach (var member in this._partyMembers)
+ {
+ if (member is not Player player || player.Attributes is not { } attributes)
+ {
+ continue;
+ }
+
+ try
+ {
+ using var _ = await player.ObserverLock.ReaderLockAsync().ConfigureAwait(false);
+ attributes[Stats.NearbyPartyMemberCount] = this._partyMembers.Count(player.Observers.Contains);
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogDebug(ex, "Error updating {Stat} for {Name}", nameof(Stats.NearbyPartyMemberCount), player.Name);
+ }
+ }
+ }
+
private async ValueTask InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
{
if (killedObject.IsSummonedMonster)
@@ -459,7 +469,7 @@ private async ValueTask InternalDistributeExperienceAfterKillAsync(IAttacka
return 0;
}
- var (total, perLevel) = CalculatePartyExperience(this._distributionList, killedObject);
+ var (total, perLevel) = this.CalculatePartyExperience(this._distributionList, killedObject);
foreach (var player in this._distributionList)
{
@@ -469,41 +479,89 @@ private async ValueTask InternalDistributeExperienceAfterKillAsync(IAttacka
return total;
}
- private async ValueTask UpdateNearbyCountAsync()
+ private async ValueTask SendPartyListAsync()
{
foreach (var member in this._partyMembers)
{
- if (member is not Player player || player.Attributes is not { } attributes)
- {
- continue;
- }
-
try
{
- using var _ = await player.ObserverLock.ReaderLockAsync().ConfigureAwait(false);
- attributes[Stats.NearbyPartyMemberCount] = this._partyMembers.Count(player.Observers.Contains);
+ await member.InvokeViewPlugInAsync(
+ p => p.UpdatePartyListAsync()).ConfigureAwait(false);
}
catch (Exception ex)
{
- this._logger.LogDebug(ex, "Error updating {Stat} for {Name}", nameof(Stats.NearbyPartyMemberCount), player.Name);
+ this._logger.LogDebug(ex, "Error sending party list to {Name}", member.Name);
}
}
}
- private async ValueTask SendPartyListAsync()
+ private (int Total, float PerLevel) CalculatePartyExperience(List recipients, IAttackable killed)
{
- foreach (var member in this._partyMembers)
+ var (levelSum, maxLevel) = this.CalculatePartyLevels(recipients);
+ var baseExp = killed.CalculateBaseExperience(maxLevel);
+
+ var totalDistributed = baseExp
+ * this._experienceBonus
+ * (killed.CurrentMap?.Definition.ExpMultiplier ?? 1);
+
+ return ((int)totalDistributed, (float)totalDistributed / levelSum);
+ }
+
+ private (int LevelSum, int MaxLevel) CalculatePartyLevels(List recipients)
+ {
+ int levelSum = 0;
+ int maxLevel = 0;
+ foreach (var player in recipients)
{
- try
+ var level = (int)player.Attributes![Stats.TotalLevel];
+ levelSum += level;
+ if (level > maxLevel)
{
- await member.InvokeViewPlugInAsync(
- p => p.UpdatePartyListAsync()).ConfigureAwait(false);
+ maxLevel = level;
}
- catch (Exception ex)
+ }
+
+ return (levelSum, maxLevel);
+ }
+
+ private void UpdateExperienceBonus()
+ {
+ var members = this._partyMembers;
+ var count = members.Length;
+ if (count < 2)
+ {
+ this._experienceBonus = 1.0;
+ return;
+ }
+
+ // Navigate to the last generation class to compare, since, currently, there is no way to look back.
+ var uniqueClasses = members
+ .Select(m => m.CharacterClass)
+ .Where(c => c != null)
+ .Select(c =>
{
- this._logger.LogDebug(ex, "Error sending party list to {Name}", member.Name);
- }
+ var current = c!;
+ while (current.NextGenerationClass is { } next)
+ {
+ current = next;
+ }
+
+ return current;
+ })
+ .Distinct()
+ .Count();
+
+ // General bonus: 2 players=1.02, 3 players=1.03, etc.
+ double bonus = 1.0 + (count * 0.01);
+
+ // Set party adds another 2% bonus.
+ bool isSetParty = uniqueClasses == count && count >= 3;
+ if (isSetParty)
+ {
+ bonus += 0.02;
}
+
+ this._experienceBonus = bonus;
}
private async Task HealthUpdateLoopAsync(CancellationToken cancellationToken)
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index caf963ffa..37e5c24a7 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -211,6 +211,9 @@ public int Money
///
public Character? SelectedCharacter => this._selectedCharacter;
+ ///
+ public CharacterClass? CharacterClass => this.SelectedCharacter?.CharacterClass;
+
///
/// Gets or sets the pose of the currently selected character.
///
@@ -1183,18 +1186,18 @@ public async ValueTask AddExpAfterKillAsync(IAttackable killedObject)
return 0;
}
- var addMasterExperience = characterClass.IsMasterClass
- && (short)this.Attributes![Stats.Level] == this.GameContext.Configuration.MaximumLevel;
- var expRateAttribute = addMasterExperience ? Stats.MasterExperienceRate : Stats.ExperienceRate;
- var gameRate = addMasterExperience ? this.GameContext.MasterExperienceRate : this.GameContext.ExperienceRate;
+ var currentLevel = (short)this.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(this.Attributes![Stats.TotalLevel]);
experience *= gameRate;
experience *= this.Attributes[expRateAttribute] + this.Attributes[Stats.BonusExperienceRate];
experience *= this.CurrentMap?.Definition.ExpMultiplier ?? 1;
- experience = Rand.NextInt((int)(experience * 0.8), (int)(experience * 1.2));
- if (addMasterExperience)
+ if (isAddMasterExperience)
{
await this.AddMasterExperienceAsync((int)experience, killedObject).ConfigureAwait(false);
}
From 46a5919631a5036fe820a0756e1e58a6c7a0250b Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Mon, 27 Apr 2026 20:33:07 -0300
Subject: [PATCH 2/2] create random xp bonus and party xp bonus configurations
---
src/GameLogic/Attributes/Stats.cs | 22 ++++-
src/GameLogic/Party.cs | 70 ++++++++++-----
src/GameLogic/Player.cs | 7 ++
.../CharacterClassInitialization.cs | 4 +-
.../GameConfigurationInitializerBase.cs | 9 +-
.../AddExperienceConfigAttributesPlugIn075.cs | 23 +++++
...AddExperienceConfigAttributesPlugIn095d.cs | 23 +++++
...AddExperienceConfigAttributesPlugInBase.cs | 89 +++++++++++++++++++
...ExperienceConfigAttributesPlugInSeason6.cs | 23 +++++
.../Initialization/Updates/UpdateVersion.cs | 15 ++++
10 files changed, 261 insertions(+), 24 deletions(-)
create mode 100644 src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugIn075.cs
create mode 100644 src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugIn095d.cs
create mode 100644 src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugInBase.cs
create mode 100644 src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugInSeason6.cs
diff --git a/src/GameLogic/Attributes/Stats.cs b/src/GameLogic/Attributes/Stats.cs
index 77b6ddb9e..7fb0ecece 100644
--- a/src/GameLogic/Attributes/Stats.cs
+++ b/src/GameLogic/Attributes/Stats.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -111,6 +111,26 @@ public class Stats
///
public static AttributeDefinition ExperienceRate { get; } = new(new Guid("1AD454D4-BEF9-416E-BC49-82A5B0277FC7"), "Experience Rate", "Defines the experience rate multiplier of a character. By default it's 1.0 and may be modified by seals or other stuff.");
+ ///
+ /// Gets the experience random min multiplier attribute definition.
+ ///
+ public static AttributeDefinition ExperienceRandomMinMultiplier { get; } = new(new Guid("536BF8B0-D24B-4314-95B7-5D651F5892DF"), "Experience Random Min Multiplier", "Defines the minimum multiplier for the randomized experience gain.");
+
+ ///
+ /// Gets the experience random max multiplier attribute definition.
+ ///
+ public static AttributeDefinition ExperienceRandomMaxMultiplier { get; } = new(new Guid("74CE26C6-6D59-4420-AF3F-457E138AE41C"), "Experience Random Max Multiplier", "Defines the maximum multiplier for the randomized experience gain.");
+
+ ///
+ /// Gets the experience rate per party member bonus attribute definition.
+ ///
+ public static AttributeDefinition ExperienceRatePerPartyMemberBonus { get; } = new(new Guid("851DE4C6-0F57-44BB-9A43-2FEE3751FCDE"), "Experience Rate Per Party Member Bonus", "Defines the bonus experience rate per party member. Default is 0.01.");
+
+ ///
+ /// Gets the experience rate bonus for set party attribute definition.
+ ///
+ public static AttributeDefinition ExperienceRateBonusForSetParty { get; } = new(new Guid("8FF5C2C7-51DE-4E0A-89D9-36CFDBB11775"), "Experience Rate Bonus For Set Party", "Defines the additional bonus experience rate for a set party (3+ different classes). Default is 0.02.");
+
///
/// Gets the bonus experience rate attribute definition, which is added to or .
///
diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs
index 5b0daa9b6..c242f007c 100644
--- a/src/GameLogic/Party.cs
+++ b/src/GameLogic/Party.cs
@@ -389,6 +389,10 @@ private async ValueTask ExitPartyAsync(IPartyMember member, byte index)
if (!shouldDispose)
{
this._partyMembers = this._partyMembers.Where(m => m != member).ToArray();
+ if (this.PartyMaster == member)
+ {
+ this.PartyMaster = this._partyMembers.FirstOrDefault();
+ }
}
this.UpdateExperienceBonus();
@@ -500,6 +504,16 @@ await member.InvokeViewPlugInAsync(
var (levelSum, maxLevel) = this.CalculatePartyLevels(recipients);
var baseExp = killed.CalculateBaseExperience(maxLevel);
+ if (recipients.FirstOrDefault()?.Attributes is { } attributes)
+ {
+ var minMultiplier = attributes[Stats.ExperienceRandomMinMultiplier];
+ var maxMultiplier = attributes[Stats.ExperienceRandomMaxMultiplier];
+ if (minMultiplier != 0 && maxMultiplier != 0)
+ {
+ baseExp = Rand.NextInt((int)(baseExp * minMultiplier), (int)(baseExp * maxMultiplier));
+ }
+ }
+
var totalDistributed = baseExp
* this._experienceBonus
* (killed.CurrentMap?.Definition.ExpMultiplier ?? 1);
@@ -534,34 +548,48 @@ private void UpdateExperienceBonus()
return;
}
- // Navigate to the last generation class to compare, since, currently, there is no way to look back.
- var uniqueClasses = members
- .Select(m => m.CharacterClass)
- .Where(c => c != null)
- .Select(c =>
- {
- var current = c!;
- while (current.NextGenerationClass is { } next)
- {
- current = next;
- }
+ var attributes = (members.FirstOrDefault() as Player)?.Attributes;
+ var perPartyMemberBonus = attributes?[Stats.ExperienceRatePerPartyMemberBonus] ?? 0.01f;
+ var setPartyBonus = attributes?[Stats.ExperienceRateBonusForSetParty] ?? 0.02f;
- return current;
- })
- .Distinct()
- .Count();
+ if (perPartyMemberBonus == 0 && setPartyBonus == 0)
+ {
+ this._experienceBonus = 1.0;
+ return;
+ }
// General bonus: 2 players=1.02, 3 players=1.03, etc.
- double bonus = 1.0 + (count * 0.01);
+ double bonus = 1.0 + (count * perPartyMemberBonus);
- // Set party adds another 2% bonus.
- bool isSetParty = uniqueClasses == count && count >= 3;
- if (isSetParty)
+ if (setPartyBonus != 0)
{
- bonus += 0.02;
+ // Navigate to the last generation class to compare, since, currently, there is no way to look back.
+ var uniqueClasses = members
+ .Select(m => m.CharacterClass)
+ .Where(c => c != null)
+ .Select(c =>
+ {
+ var current = c!;
+ while (current.NextGenerationClass is { } next)
+ {
+ current = next;
+ }
+
+ return current;
+ })
+ .Distinct()
+ .Count();
+
+ // Set party adds an extra flat bonus on top of the general bonus.
+ bool isSetParty = uniqueClasses == count && count >= 3;
+ if (isSetParty)
+ {
+ bonus += setPartyBonus;
+ }
}
- this._experienceBonus = bonus;
+ // Avoid negative xp values.
+ this._experienceBonus = Math.Max(0, bonus);
}
private async Task HealthUpdateLoopAsync(CancellationToken cancellationToken)
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index 512681cb1..bad724ad4 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -1198,6 +1198,13 @@ public async ValueTask AddExpAfterKillAsync(IAttackable killedObject)
experience *= this.Attributes[expRateAttribute] + this.Attributes[Stats.BonusExperienceRate];
experience *= this.CurrentMap?.Definition.ExpMultiplier ?? 1;
+ var minMultiplier = this.Attributes[Stats.ExperienceRandomMinMultiplier];
+ var maxMultiplier = this.Attributes[Stats.ExperienceRandomMaxMultiplier];
+ if (minMultiplier > 0 && maxMultiplier > 0)
+ {
+ experience = Rand.NextInt((int)(experience * minMultiplier), (int)(experience * maxMultiplier));
+ }
+
if (isAddMasterExperience)
{
await this.AddMasterExperienceAsync((int)experience, killedObject).ConfigureAwait(false);
diff --git a/src/Persistence/Initialization/CharacterClasses/CharacterClassInitialization.cs b/src/Persistence/Initialization/CharacterClasses/CharacterClassInitialization.cs
index 59c0f3c99..f7cfe2836 100644
--- a/src/Persistence/Initialization/CharacterClasses/CharacterClassInitialization.cs
+++ b/src/Persistence/Initialization/CharacterClasses/CharacterClassInitialization.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -160,6 +160,8 @@ private void AddCommonBaseAttributeValues(ICollection baseA
baseAttributeValues.Add(this.CreateConstValueAttribute(1, Stats.DamageReceiveDecrement));
baseAttributeValues.Add(this.CreateConstValueAttribute(1, Stats.AttackDamageIncrease));
baseAttributeValues.Add(this.CreateConstValueAttribute(1, Stats.ExperienceRate));
+ baseAttributeValues.Add(this.CreateConstValueAttribute(0.01f, Stats.ExperienceRatePerPartyMemberBonus));
+ baseAttributeValues.Add(this.CreateConstValueAttribute(0.02f, Stats.ExperienceRateBonusForSetParty));
baseAttributeValues.Add(this.CreateConstValueAttribute(0.03f, Stats.PoisonDamageMultiplier));
baseAttributeValues.Add(this.CreateConstValueAttribute(1, Stats.ItemDurationIncrease));
baseAttributeValues.Add(this.CreateConstValueAttribute(2, Stats.AbilityRecoveryAbsolute));
diff --git a/src/Persistence/Initialization/GameConfigurationInitializerBase.cs b/src/Persistence/Initialization/GameConfigurationInitializerBase.cs
index ce1adf406..9c9a905d0 100644
--- a/src/Persistence/Initialization/GameConfigurationInitializerBase.cs
+++ b/src/Persistence/Initialization/GameConfigurationInitializerBase.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -238,8 +238,15 @@ private void AddGlobalBaseAttributeValues()
{
var moneyAmountRate = this.Context.CreateNew(1f, Stats.MoneyAmountRate.GetPersistent(this.GameConfiguration));
this.GameConfiguration.GlobalBaseAttributeValues.Add(moneyAmountRate);
+
+ var experienceRandomMinMultiplier = this.Context.CreateNew(0.8f, Stats.ExperienceRandomMinMultiplier.GetPersistent(this.GameConfiguration));
+ this.GameConfiguration.GlobalBaseAttributeValues.Add(experienceRandomMinMultiplier);
+
+ var experienceRandomMaxMultiplier = this.Context.CreateNew(1.2f, Stats.ExperienceRandomMaxMultiplier.GetPersistent(this.GameConfiguration));
+ this.GameConfiguration.GlobalBaseAttributeValues.Add(experienceRandomMaxMultiplier);
}
+
private long CalcNeededMasterExp(long lvl)
{
// f(x) = 505 * x^3 + 35278500 * x + 228045 * x^2
diff --git a/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugIn075.cs b/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugIn075.cs
new file mode 100644
index 000000000..3bddb322a
--- /dev/null
+++ b/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugIn075.cs
@@ -0,0 +1,23 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.Updates;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// This update adds the experience config attributes for version 0.75.
+///
+[PlugIn]
+[Display(Name = PlugInName, Description = PlugInDescription)]
+[Guid("5F412933-CC0F-483B-B6AE-7B358A6257FD")]
+public class AddExperienceConfigAttributesPlugIn075 : AddExperienceConfigAttributesPlugInBase
+{
+ ///
+ public override UpdateVersion Version => UpdateVersion.AddExperienceConfigAttributes075;
+
+ ///
+ public override string DataInitializationKey => Version075.DataInitialization.Id;
+}
diff --git a/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugIn095d.cs b/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugIn095d.cs
new file mode 100644
index 000000000..6cf3de51c
--- /dev/null
+++ b/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugIn095d.cs
@@ -0,0 +1,23 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.Updates;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// This update adds the experience config attributes for version 0.95d.
+///
+[PlugIn]
+[Display(Name = PlugInName, Description = PlugInDescription)]
+[Guid("9A166583-C3E7-4E04-924C-F01FF9840974")]
+public class AddExperienceConfigAttributesPlugIn095d : AddExperienceConfigAttributesPlugInBase
+{
+ ///
+ public override UpdateVersion Version => UpdateVersion.AddExperienceConfigAttributes095d;
+
+ ///
+ public override string DataInitializationKey => Version095d.DataInitialization.Id;
+}
diff --git a/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugInBase.cs b/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugInBase.cs
new file mode 100644
index 000000000..50690ec3e
--- /dev/null
+++ b/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugInBase.cs
@@ -0,0 +1,89 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.Updates;
+
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.Attributes;
+
+///
+/// This update adds the experience configuration attributes to the database
+/// (ExperienceRandomMinMultiplier, ExperienceRandomMaxMultiplier,
+/// ExperienceRatePerPartyMemberBonus, ExperienceRateBonusForSetParty)
+/// and assigns their default values to global and class base attributes.
+///
+public abstract class AddExperienceConfigAttributesPlugInBase : UpdatePlugInBase
+{
+ ///
+ /// The plug in name.
+ ///
+ internal const string PlugInName = "Add Experience Config Attributes";
+
+ ///
+ /// The plug in description.
+ ///
+ internal const string PlugInDescription = "Adds new experience configuration attributes to the game configuration.";
+
+ ///
+ public override string Name => PlugInName;
+
+ ///
+ public override string Description => PlugInDescription;
+
+ ///
+ public override bool IsMandatory => true;
+
+ ///
+ public override DateTime CreatedAt => new(2026, 04, 27, 20, 0, 0, DateTimeKind.Utc);
+
+ ///
+ protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
+ {
+ var attributesToAdd = new[]
+ {
+ Stats.ExperienceRandomMinMultiplier,
+ Stats.ExperienceRandomMaxMultiplier,
+ Stats.ExperienceRatePerPartyMemberBonus,
+ Stats.ExperienceRateBonusForSetParty,
+ };
+
+ foreach (var attr in attributesToAdd)
+ {
+ if (!gameConfiguration.Attributes.Any(a => a.Id == attr.Id))
+ {
+ var newAttr = context.CreateNew(attr.Id, attr.Designation, attr.Description);
+ gameConfiguration.Attributes.Add(newAttr);
+ }
+ }
+
+ var randMin = gameConfiguration.Attributes.First(a => a.Id == Stats.ExperienceRandomMinMultiplier.Id);
+ var randMax = gameConfiguration.Attributes.First(a => a.Id == Stats.ExperienceRandomMaxMultiplier.Id);
+ var partyRate = gameConfiguration.Attributes.First(a => a.Id == Stats.ExperienceRatePerPartyMemberBonus.Id);
+ var partySet = gameConfiguration.Attributes.First(a => a.Id == Stats.ExperienceRateBonusForSetParty.Id);
+
+ if (!gameConfiguration.GlobalBaseAttributeValues.Any(a => a.Definition?.Id == randMin.Id))
+ {
+ gameConfiguration.GlobalBaseAttributeValues.Add(context.CreateNew(0.8f, randMin));
+ }
+
+ if (!gameConfiguration.GlobalBaseAttributeValues.Any(a => a.Definition?.Id == randMax.Id))
+ {
+ gameConfiguration.GlobalBaseAttributeValues.Add(context.CreateNew(1.2f, randMax));
+ }
+
+ foreach (var characterClass in gameConfiguration.CharacterClasses)
+ {
+ if (!characterClass.BaseAttributeValues.Any(a => a.Definition?.Id == partyRate.Id))
+ {
+ characterClass.BaseAttributeValues.Add(context.CreateNew(0.01f, partyRate));
+ }
+
+ if (!characterClass.BaseAttributeValues.Any(a => a.Definition?.Id == partySet.Id))
+ {
+ characterClass.BaseAttributeValues.Add(context.CreateNew(0.02f, partySet));
+ }
+ }
+ }
+}
diff --git a/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugInSeason6.cs b/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugInSeason6.cs
new file mode 100644
index 000000000..fa3c8d73c
--- /dev/null
+++ b/src/Persistence/Initialization/Updates/AddExperienceConfigAttributesPlugInSeason6.cs
@@ -0,0 +1,23 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.Updates;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// This update adds the experience config attributes for season 6.
+///
+[PlugIn]
+[Display(Name = PlugInName, Description = PlugInDescription)]
+[Guid("D1DC70A2-2614-4CC0-81C0-6C8253781019")]
+public class AddExperienceConfigAttributesPlugInSeason6 : AddExperienceConfigAttributesPlugInBase
+{
+ ///
+ public override UpdateVersion Version => UpdateVersion.AddExperienceConfigAttributesSeason6;
+
+ ///
+ public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
+}
diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs
index 88de6ca39..484ad9e57 100644
--- a/src/Persistence/Initialization/Updates/UpdateVersion.cs
+++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs
@@ -402,4 +402,19 @@ public enum UpdateVersion
/// The version of the .
///
FinishDarkLordMasterTree = 79,
+
+ ///
+ /// The version of the .
+ ///
+ AddExperienceConfigAttributes075 = 80,
+
+ ///
+ /// The version of the .
+ ///
+ AddExperienceConfigAttributes095d = 81,
+
+ ///
+ /// The version of the .
+ ///
+ AddExperienceConfigAttributesSeason6 = 82,
}