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/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..c242f007c 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!; @@ -402,7 +389,13 @@ 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(); } if (shouldDispose) @@ -439,6 +432,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 +473,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 +483,113 @@ 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); + + if (recipients.FirstOrDefault()?.Attributes is { } attributes) { - try + var minMultiplier = attributes[Stats.ExperienceRandomMinMultiplier]; + var maxMultiplier = attributes[Stats.ExperienceRandomMaxMultiplier]; + if (minMultiplier != 0 && maxMultiplier != 0) { - await member.InvokeViewPlugInAsync( - p => p.UpdatePartyListAsync()).ConfigureAwait(false); + baseExp = Rand.NextInt((int)(baseExp * minMultiplier), (int)(baseExp * maxMultiplier)); } - catch (Exception ex) + } + + 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) + { + var level = (int)player.Attributes![Stats.TotalLevel]; + levelSum += level; + if (level > maxLevel) { - this._logger.LogDebug(ex, "Error sending party list to {Name}", member.Name); + maxLevel = level; } } + + return (levelSum, maxLevel); + } + + private void UpdateExperienceBonus() + { + var members = this._partyMembers; + var count = members.Length; + if (count < 2) + { + this._experienceBonus = 1.0; + return; + } + + var attributes = (members.FirstOrDefault() as Player)?.Attributes; + var perPartyMemberBonus = attributes?[Stats.ExperienceRatePerPartyMemberBonus] ?? 0.01f; + var setPartyBonus = attributes?[Stats.ExperienceRateBonusForSetParty] ?? 0.02f; + + 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 * perPartyMemberBonus); + + if (setPartyBonus != 0) + { + // 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; + } + } + + // 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 ed06901e0..bad724ad4 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -212,6 +212,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. /// @@ -1184,18 +1187,25 @@ 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) + 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, }