diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs new file mode 100644 index 0000000000..4bd588c91b --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -0,0 +1,594 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas; + +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Attributes; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// Tests that the item requirement, power-up and price formulas give for every item of every version's data +/// what the server computed at commit 90a571100, which the frozen copies in the Legacy folder keep. +/// +/// +/// Each item is checked at every item level, without a normal option and with each of its normal options at the option's +/// first and highest level (and with all of its fixed options), +/// in every slot it fits, at full durability and, at the lowest and highest item level, below each threshold of the +/// durability factor (at 40, 25 and 10 % of it): as a normal item, as an excellent item with all its +/// excellent options and with one of them, as an ancient item and as an excellent ancient item. A wearable item which +/// can't be excellent or ancient in the data gets a made-up excellent option or ancient set, so that every kind of item +/// runs through these formulas. The requirements are checked with the item's own requirements and with made-up ones of +/// each stat (0, 100 and 100 from item level 7); and, with the item's own and the made-up ones of 100, with each harmony +/// option which reduces a requirement (at its lowest and highest level) and, on a normal item without its option, with a +/// made-up reduction of each stat. The prices are compared at full, 40 % and no durability (a stackable item with one piece, +/// a full stack and none), and, at full durability, also with the item's skill, luck, wing options and option of the Jewel +/// of Guardian. All differences are listed in one report. +/// +/// The test guards moving these formulas into configuration data, whose default values have to give the same results. +/// After an intentional change of one of the formulas (e.g. a fixed price bug), change the frozen copy the same way in the +/// same pull request and say so there. The copies and this test can be removed once the formulas are configuration data +/// and its default values are tested otherwise. +/// +/// +[TestFixture] +internal class ItemFormulasEqualTodayTest +{ + /// + /// Tests the items of the 0.75 data. + /// + [Test] + public async Task Version075Async() + { + var (context, gameConfiguration) = await TestGameConfiguration.Create075Async().ConfigureAwait(false); + using var _ = context; + AssertFormulasEqualToday(context, gameConfiguration); + } + + /// + /// Tests the items of the 0.95d data. + /// + [Test] + public async Task Version095dAsync() + { + var (context, gameConfiguration) = await TestGameConfiguration.Create095dAsync().ConfigureAwait(false); + using var _ = context; + AssertFormulasEqualToday(context, gameConfiguration); + } + + /// + /// Tests the items of the Season 6 data. + /// + [Test] + public async Task Season6Async() + { + var (context, gameConfiguration) = await TestGameConfiguration.CreateSeason6Async().ConfigureAwait(false); + using var _ = context; + AssertFormulasEqualToday(context, gameConfiguration); + } + + private static void AssertFormulasEqualToday(IContext context, GameConfiguration gameConfiguration) + { + var comparison = new FormulaComparison(context); + foreach (var definition in gameConfiguration.Items.OrderBy(d => d.Group).ThenBy(d => d.Number)) + { + comparison.CompareItem(definition); + } + + Assert.Multiple(() => + { + Assert.That(comparison.RequirementCases, Is.GreaterThan(0)); + Assert.That(comparison.PowerUpCases, Is.GreaterThan(0)); + Assert.That(comparison.PriceCases, Is.GreaterThan(0)); + Assert.That(comparison.Differences, Is.Empty, comparison.GetReport()); + }); + } + + /// + /// A quality of an item: which excellent options it has and to which ancient set it belongs. + /// + /// The name in the report. + /// The excellent options. + /// The ancient set item, if it's ancient. + private sealed record ItemQuality(string Name, IReadOnlyList ExcellentOptions, ItemOfItemSet? AncientSetItem) + { + /// + /// Gets a value indicating whether the item is neither excellent nor ancient. + /// + public bool IsNormal => this.ExcellentOptions.Count == 0 && this.AncientSetItem is null; + } + + /// + /// The normal options of an item, at a level. + /// + /// The options. + /// The level of the options. + /// The name in the report. + private sealed record OptionVariant(IReadOnlyList Options, int Level, string Name); + + /// + /// An option which reduces a requirement, at a level. + /// + /// The option. + /// The level of the option. + /// The name in the report. + private sealed record ReductionOption(IncreasableItemOption Option, int Level, string Name); + + /// + /// Compares the frozen formulas with the current ones and collects the differences. + /// + private sealed class FormulaComparison + { + private const int MaximumReportedDifferences = 50; + + private const int MadeUpReduction = 7; + + /// + /// The total level of the character who wears the items, which some power-ups follow, e.g. the excellent "damage +level/20". + /// + private const int CharacterTotalLevel = 400; + + private static readonly IReadOnlyDictionary RequirementReductions = new Dictionary + { + { Stats.TotalStrengthRequirementValue, Stats.RequiredStrengthReduction }, + { Stats.TotalAgilityRequirementValue, Stats.RequiredAgilityReduction }, + { Stats.TotalEnergyRequirementValue, Stats.RequiredEnergyReduction }, + { Stats.TotalVitalityRequirementValue, Stats.RequiredVitalityReduction }, + { Stats.TotalLeadershipRequirementValue, Stats.RequiredLeadershipReduction }, + }; + + private readonly Legacy.LegacyItemPowerUpFactory _legacyPowerUps = new(NullLogger.Instance); + + private readonly ItemPowerUpFactory _powerUps = new(NullLogger.Instance); + + private readonly Legacy.LegacyItemPriceCalculator _legacyPrices = new(); + + private readonly ItemPriceCalculator _prices = new(); + + private readonly IncreasableItemOption _madeUpExcellentOption; + + private readonly ItemOfItemSet _madeUpAncientSetItem; + + private readonly List _madeUpRequirements = []; + + private readonly List _madeUpReducedRequirements = []; + + private readonly List _madeUpReductions = []; + + /// + /// Initializes a new instance of the class. + /// + /// The context which creates the made-up options, sets and requirements. They aren't saved. + public FormulaComparison(IContext context) + { + this._madeUpExcellentOption = context.CreateNew(); + this._madeUpExcellentOption.OptionType = ItemOptionTypes.Excellent; + + this._madeUpAncientSetItem = context.CreateNew(); + this._madeUpAncientSetItem.AncientSetDiscriminator = 1; + + foreach (var (requirementAttribute, reductionAttribute) in RequirementReductions) + { + var requirement = CreateRequirement(context, requirementAttribute, 100, null); + this._madeUpReducedRequirements.Add(requirement); + this._madeUpRequirements.Add(CreateRequirement(context, requirementAttribute, 0, null)); + this._madeUpRequirements.Add(requirement); + this._madeUpRequirements.Add(CreateRequirement(context, requirementAttribute, 100, 7)); + + var reductionOption = context.CreateNew(); + reductionOption.OptionType = ItemOptionTypes.HarmonyOption; + reductionOption.PowerUpDefinition = context.CreateNew(); + reductionOption.PowerUpDefinition.TargetAttribute = reductionAttribute; + reductionOption.PowerUpDefinition.Boost = context.CreateNew(); + reductionOption.PowerUpDefinition.Boost.ConstantValue.Value = MadeUpReduction; + this._madeUpReductions.Add(new ReductionOption(reductionOption, 0, $"made-up {reductionAttribute.Designation} {MadeUpReduction}")); + } + } + + /// + /// Gets the differences, one text per difference. + /// + public List Differences { get; } = []; + + /// + /// Gets the number of differences per compared value, e.g. "requirements" or "buying price". + /// + public SortedDictionary DifferencesByKind { get; } = new(StringComparer.Ordinal); + + /// + /// Gets the number of compared requirement cases. + /// + public int RequirementCases { get; private set; } + + /// + /// Gets the number of compared power-up cases. + /// + public int PowerUpCases { get; private set; } + + /// + /// Gets the number of compared price cases. + /// + public int PriceCases { get; private set; } + + /// + /// Compares all formulas of the item in all its variants. + /// + /// The item definition. + public void CompareItem(ItemDefinition definition) + { + var possibleOptions = definition.PossibleItemOptions.SelectMany(o => o.PossibleOptions).ToList(); + + var harmonyReductions = new List(); + foreach (var harmonyOption in possibleOptions.Where(o => o.OptionType == ItemOptionTypes.HarmonyOption && ReducesRequirement(o))) + { + var levels = harmonyOption.LevelDependentOptions.Select(o => o.Level).DefaultIfEmpty(0).ToList(); + foreach (var harmonyLevel in new[] { levels.Min(), levels.Max() }.Distinct()) + { + harmonyReductions.Add(new ReductionOption(harmonyOption, harmonyLevel, $"harmony {harmonyOption.Number} level {harmonyLevel}")); + } + } + + var allReductions = harmonyReductions.Concat(this._madeUpReductions).ToList(); + + // What the prices add a surcharge for, besides the excellent and normal options: luck, each wing option and the + // option of the Jewel of Guardian (and a skill, see ComparePrices). + var priceExtras = possibleOptions.Where(o => o.OptionType == ItemOptionTypes.Luck).Take(1) + .Concat(possibleOptions.Where(o => o.OptionType == ItemOptionTypes.Wing)) + .Concat(possibleOptions.Where(o => o.OptionType == ItemOptionTypes.GuardianOption).Take(1)) + .ToList(); + + // An item has one of its options at a time (e.g. a wing either recovers health or adds damage), except the + // fixed options (e.g. of the Dinorant), which can be on the item together. + // Level 1 of an option is its own power-up, the higher levels are in its level dependent options; a fixed + // option has only level 1. The option adds its level times a factor to the strength requirement, so the + // first and the highest level show the factor. + var options = possibleOptions.Where(o => o.OptionType == ItemOptionTypes.Option).ToList(); + var optionVariants = new List { new([], 0, "without option") }; + foreach (var option in options) + { + foreach (var optionLevel in new[] { 1, option.LevelDependentOptions.Select(o => o.Level).DefaultIfEmpty(1).Max() }.Distinct()) + { + optionVariants.Add(new OptionVariant([option], optionLevel, $"option {option.Number} level {optionLevel}")); + } + } + + if (options.Count > 1 && options.All(o => o.LevelDependentOptions.Count == 0)) + { + optionVariants.Add(new OptionVariant(options, 1, "all fixed options")); + } + + foreach (var quality in this.GetQualities(definition, possibleOptions)) + { + for (int level = 0; level <= definition.MaximumItemLevel; level++) + { + foreach (var optionVariant in optionVariants) + { + var item = new TemporaryItem { Definition = definition, Level = (byte)level }; + foreach (var excellentOption in quality.ExcellentOptions) + { + item.ItemOptions.Add(new ItemOptionLink { ItemOption = excellentOption }); + } + + if (quality.AncientSetItem is { } ancientSetItem) + { + AddAncientSet(item, ancientSetItem); + } + + foreach (var option in optionVariant.Options) + { + item.ItemOptions.Add(new ItemOptionLink { ItemOption = option, Level = optionVariant.Level }); + } + + var itemCase = $"{definition.Group}/{definition.Number} {definition.Name.ValueInNeutralLanguage} +{level} {quality.Name}, {optionVariant.Name}"; + this.CompareRequirementsWithReductions(item, quality.IsNormal && optionVariant.Options.Count == 0 ? allReductions : harmonyReductions, itemCase); + if (this.GetMaximumDurability(item, itemCase) is { } maximumDurability) + { + // The durability factor multiplies the same parts at every item level, so the lowest and the + // highest one show it. + var allDurabilities = level == 0 || level == definition.MaximumItemLevel; + this.ComparePowerUps(item, maximumDurability, allDurabilities, itemCase); + this.ComparePrices(item, maximumDurability, priceExtras, itemCase); + } + } + } + } + } + + /// + /// Gets the report of the differences. + /// + /// The report. + public string GetReport() + { + var report = new StringBuilder(); + report.AppendLine(CultureInfo.InvariantCulture, $"{this.Differences.Count} differences in {this.RequirementCases} requirement, {this.PowerUpCases} power-up and {this.PriceCases} price cases"); + report.AppendLine(CultureInfo.InvariantCulture, $"({string.Join(", ", this.DifferencesByKind.Select(d => $"{d.Key}: {d.Value}"))}):"); + foreach (var difference in this.Differences.Take(MaximumReportedDifferences)) + { + report.AppendLine(difference); + } + + if (this.Differences.Count > MaximumReportedDifferences) + { + report.AppendLine(CultureInfo.InvariantCulture, $"... and {this.Differences.Count - MaximumReportedDifferences} more."); + } + + return report.ToString(); + } + + private static AttributeRequirement CreateRequirement(IContext context, AttributeDefinition attribute, int value, byte? minimumItemLevel) + { + var requirement = context.CreateNew(); + requirement.Attribute = attribute; + requirement.MinimumValue = value; + requirement.MinimumItemLevel = minimumItemLevel; + return requirement; + } + + private static bool ReducesRequirement(IncreasableItemOption option) + { + return (option.PowerUpDefinition?.TargetAttribute is { } target && RequirementReductions.Values.Contains(target)) + || option.LevelDependentOptions.Any(l => l.PowerUpDefinition?.TargetAttribute is { } levelTarget && RequirementReductions.Values.Contains(levelTarget)); + } + + private static void AddAncientSet(Item item, ItemOfItemSet ancientSetItem) + { + item.ItemSetGroups.Add(ancientSetItem); + if (ancientSetItem.BonusOption is { } bonusOption) + { + var bonusLevel = bonusOption.LevelDependentOptions.Select(o => o.Level).DefaultIfEmpty(0).Max(); + item.ItemOptions.Add(new ItemOptionLink { ItemOption = bonusOption, Level = bonusLevel }); + } + } + + private static string Capture(Func calculation) + { + try + { + return calculation(); + } + catch (Exception ex) + { + return DescribeException(ex); + } + } + + /// + /// Describes an exception by its type and message, so that two different failures of the same type differ. + /// Not by the method which threw it: the live code may compute the same in other methods. + /// + private static string DescribeException(Exception exception) + { + return $"throws {exception.GetType().Name}: {exception.Message}"; + } + + /// + /// Gets the durabilities to compare: the full one, and one below each threshold of the durability factor + /// (50, 30 and 20 % of the full durability), so that each factor applies. + /// + private static IEnumerable GetDurabilities(byte fullDurability) + { + return new[] { fullDurability, fullDurability * 4 / 10, fullDurability / 4, fullDurability / 10 }.Distinct(); + } + + private static string DescribePowerUps(Item item, Func> getPowerUps) + { + using var attributes = new AttributeSystem([], [], []); + attributes.AddElement(new ConstantElement(CharacterTotalLevel), Stats.TotalLevel); + var powerUps = new List(); + try + { + // The power-ups before a throw are compared too. + string thrown = string.Empty; + try + { + powerUps.AddRange(getPowerUps(item, attributes)); + } + catch (Exception ex) + { + thrown = $"; then {DescribeException(ex)}"; + } + + // Each power-up added itself to its target attribute, which tells the attribute. + var elements = attributes.OfType() + .SelectMany(attribute => attribute.Elements.OfType().Select(element => + $"{attribute.Definition.Designation} {element.AggregateType} {element.Value.ToString("R", CultureInfo.InvariantCulture)}")) + .Order(StringComparer.Ordinal); + return $"{powerUps.Count}: {string.Join("; ", elements)}{thrown}"; + } + finally + { + // Power-ups of shared option values subscribe to them, so they are removed again. + powerUps.ForEach(powerUp => powerUp.Dispose()); + } + } + + private static string DescribeRequirements(Item item, IEnumerable madeUpRequirements, Func getRequirement) + { + // Each requirement on its own, so that one which throws doesn't hide the others. + string Describe(IEnumerable requirements) => string.Join("; ", requirements.Select(requirement => + { + try + { + var (attribute, value) = getRequirement(item, requirement); + return $"{attribute.Designation} {value}"; + } + catch (Exception ex) + { + return $"{requirement.Attribute?.Designation} {DescribeException(ex)}"; + } + })); + + return $"{Describe(item.Definition!.Requirements)}; made-up: {Describe(madeUpRequirements)}"; + } + + private IEnumerable GetQualities(ItemDefinition definition, List possibleOptions) + { + var isWearable = definition.ItemSlot is not null; + IReadOnlyList excellentOptions = possibleOptions.Where(o => o.OptionType == ItemOptionTypes.Excellent).ToList(); + var excellent = "excellent"; + if (excellentOptions.Count == 0 && isWearable) + { + excellentOptions = [this._madeUpExcellentOption]; + excellent = "made-up excellent"; + } + + var ancientSetItem = definition.PossibleItemSetGroups + .Where(g => g.Options?.PossibleOptions.Any(o => o.OptionType == ItemOptionTypes.AncientOption) ?? false) + .SelectMany(g => g.Items) + .FirstOrDefault(i => object.Equals(i.ItemDefinition, definition) && i.AncientSetDiscriminator > 0); + var ancient = "ancient"; + if (ancientSetItem is null && isWearable) + { + ancientSetItem = this._madeUpAncientSetItem; + ancient = "made-up ancient"; + } + + yield return new ItemQuality("normal", [], null); + if (excellentOptions.Count > 0) + { + yield return new ItemQuality(excellent, excellentOptions, null); + } + + if (excellentOptions.Count > 1) + { + // Not the damage increase option, which decides about a power-up of double wielded excellent weapons. + var oneOption = excellentOptions.FirstOrDefault(o => o.PowerUpDefinition?.TargetAttribute != Stats.PhysicalBaseDmgIncrease) ?? excellentOptions[0]; + yield return new ItemQuality($"{excellent} with only option {oneOption.Number}", [oneOption], null); + } + + if (ancientSetItem is not null) + { + yield return new ItemQuality(ancient, [], ancientSetItem); + if (excellentOptions.Count > 0) + { + yield return new ItemQuality($"{excellent} {ancient}", excellentOptions, ancientSetItem); + } + } + } + + private void Compare(string itemCase, string what, string before, string now) + { + if (before != now) + { + this.AddDifference(itemCase, what, $"before [{before}], now [{now}]"); + } + } + + private void AddDifference(string itemCase, string what, string description) + { + this.Differences.Add($"{itemCase}, {what}: {description}"); + this.DifferencesByKind[what] = this.DifferencesByKind.GetValueOrDefault(what) + 1; + } + + /// + /// Gets the maximum durability of the item, with which the power-up and price cases are prepared. + /// If it can't be computed, it's listed as a difference, and the item's power-ups and prices aren't compared. + /// + private byte? GetMaximumDurability(Item item, string itemCase) + { + try + { + return item.GetMaximumDurabilityOfOnePiece(); + } + catch (Exception ex) + { + this.AddDifference(itemCase, "maximum durability", DescribeException(ex)); + return null; + } + } + + private void CompareRequirementsWithReductions(Item item, List reductions, string itemCase) + { + this.CompareRequirements(item, this._madeUpRequirements, itemCase); + foreach (var reduction in reductions) + { + var reductionLink = new ItemOptionLink { ItemOption = reduction.Option, Level = reduction.Level }; + item.ItemOptions.Add(reductionLink); + this.CompareRequirements(item, this._madeUpReducedRequirements, $"{itemCase}, {reduction.Name}"); + item.ItemOptions.Remove(reductionLink); + } + } + + private void CompareRequirements(Item item, List madeUpRequirements, string itemCase) + { + this.RequirementCases++; + this.Compare( + itemCase, + "requirements", + Capture(() => DescribeRequirements(item, madeUpRequirements, Legacy.LegacyItemExtensions.GetRequirement)), + Capture(() => DescribeRequirements(item, madeUpRequirements, (i, r) => i.GetRequirement(r)))); + } + + private void ComparePowerUps(Item item, byte fullDurability, bool allDurabilities, string itemCase) + { + if (item.Definition!.ItemSlot is not { } slotType) + { + return; + } + + foreach (var slot in slotType.ItemSlots) + { + foreach (var durability in allDurabilities ? GetDurabilities(fullDurability) : [fullDurability]) + { + item.ItemSlot = (byte)slot; + item.Durability = durability; + this.PowerUpCases++; + this.Compare( + $"{itemCase}, slot {slot}, durability {durability}", + "power-ups", + Capture(() => DescribePowerUps(item, this._legacyPowerUps.GetPowerUps)), + Capture(() => DescribePowerUps(item, this._powerUps.GetPowerUps))); + } + } + } + + private void ComparePrices(Item item, byte fullDurability, List priceExtras, string itemCase) + { + item.ItemSlot = (byte)(item.Definition!.ItemSlot?.ItemSlots.FirstOrDefault() ?? 0); + + // The prices have no durability factor: they follow the missing durability, and the durability of a stackable + // item is the number of pieces, which the prices of potions, arrows and others multiply. + var durabilities = item.IsStackable() + ? new[] { fullDurability, item.Definition.Durability, 0 }.Distinct().ToList() + : new[] { fullDurability, fullDurability * 4 / 10, 0 }.Distinct().ToList(); + this.ComparePricesAtDurabilities(item, durabilities, itemCase); + if (priceExtras.Count == 0 && item.Definition!.Skill is null) + { + return; + } + + // An item as the merchants sell it: with its skill and luck, and wings with their wing options. + var extraLinks = priceExtras.Select(option => new ItemOptionLink { ItemOption = option, Level = 1 }).ToList(); + extraLinks.ForEach(item.ItemOptions.Add); + item.HasSkill = item.Definition!.Skill is not null; + this.ComparePricesAtDurabilities(item, [fullDurability], $"{itemCase}, with {(item.HasSkill ? "skill, " : string.Empty)}{string.Join(", ", priceExtras.Select(o => o.OptionType?.Name.ValueInNeutralLanguage))}"); + item.HasSkill = false; + extraLinks.ForEach(link => item.ItemOptions.Remove(link)); + } + + private void ComparePricesAtDurabilities(Item item, List durabilities, string itemCase) + { + foreach (var durability in durabilities) + { + item.Durability = durability; + this.PriceCases++; + var priceCase = $"{itemCase}, durability {durability}"; + this.Compare(priceCase, "buying price", Capture(() => $"{this._legacyPrices.CalculateFinalBuyingPrice(item)}"), Capture(() => $"{this._prices.CalculateFinalBuyingPrice(item)}")); + this.Compare(priceCase, "old buying price", Capture(() => $"{this._legacyPrices.CalculateFinalOldBuyingPrice(item)}"), Capture(() => $"{this._prices.CalculateFinalOldBuyingPrice(item)}")); + this.Compare(priceCase, "selling price", Capture(() => $"{this._legacyPrices.CalculateSellingPrice(item)}"), Capture(() => $"{this._prices.CalculateSellingPrice(item)}")); + this.Compare(priceCase, "selling price at durability", Capture(() => $"{this._legacyPrices.CalculateSellingPrice(item, item.Durability())}"), Capture(() => $"{this._prices.CalculateSellingPrice(item, item.Durability())}")); + this.Compare(priceCase, "repair price", Capture(() => $"{this._legacyPrices.CalculateRepairPrice(item, false)}"), Capture(() => $"{this._prices.CalculateRepairPrice(item, false)}")); + this.Compare(priceCase, "repair price with discount", Capture(() => $"{this._legacyPrices.CalculateRepairPrice(item, true)}"), Capture(() => $"{this._prices.CalculateRepairPrice(item, true)}")); + } + } + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs new file mode 100644 index 0000000000..1712966ed4 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs @@ -0,0 +1,401 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; + +using System.Diagnostics.CodeAnalysis; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// A frozen copy of the item requirement formulas in src/GameLogic/ItemExtensions.cs at commit 90a571100, +/// with the item checks and durability functions which the three frozen copies use, +/// which compares with the current code. +/// Change it only together with an intentional change of the formula, see the remarks of the test. +/// +/// +/// Lines 19-106, 131-141, 174-265, 279-289, 361-426, 474-500, 512-542 and 544-561 are copied unchanged, and between the +/// last two, lines 14-27 of src/GameLogic/ItemOptionExtensions.cs (IsFixedOption). +/// The extension method calls in the frozen copies bind to this class wherever it has a method of that name, +/// because extension methods of the own namespace come before imported ones; the copies don't import +/// MUnique.OpenMU.GameLogic, so none of its extension methods can change the copies and the live code together. +/// They still use live code which the item formulas don't contain: IsWearable, IsTrainablePet, +/// IsDarkRaven, IsWing and Durability of src/DataModel/ItemExtensions.cs; the code which builds +/// the elements of the power-ups and applies the durability factor to them: PowerUpWrapper.CreateByPowerUpDefinition +/// of MUnique.OpenMU.GameLogic.Attributes, and PowerUpDefinitionValue.GetSimpleValueElement, +/// ItemBasePowerUpDefinition.GetBaseValueElement and LevelBonus.GetAdditionalValueElement of the data model; +/// and the attribute definitions of Stats, including the ElementResistanceToDamageBonus map of the ancient jewelry bonus. +/// +internal static class LegacyItemExtensions +{ + private const byte ShieldItemGroup = 6; + + private static readonly byte[] AdditionalDurabilityPerLevel = { 0, 1, 2, 3, 4, 6, 8, 10, 12, 14, 17, 21, 26, 32, 39, 47 }; + + private static readonly float[] DurabilityFactors = { 0.2f, 0.3f, 0.5f }; + + private static readonly IDictionary RequirementAttributeMapping = new Dictionary + { + { Stats.TotalStrengthRequirementValue, Stats.TotalStrength }, + { Stats.TotalAgilityRequirementValue, Stats.TotalAgility }, + { Stats.TotalEnergyRequirementValue, Stats.TotalEnergy }, + { Stats.TotalVitalityRequirementValue, Stats.TotalVitality }, + { Stats.TotalLeadershipRequirementValue, Stats.TotalLeadership }, + }; + + private static readonly IDictionary RequirementReductionAttributeMapping = new Dictionary + { + { Stats.TotalStrengthRequirementValue, Stats.RequiredStrengthReduction }, + { Stats.TotalAgilityRequirementValue, Stats.RequiredAgilityReduction }, + { Stats.TotalEnergyRequirementValue, Stats.RequiredEnergyReduction }, + { Stats.TotalVitalityRequirementValue, Stats.RequiredVitalityReduction }, + { Stats.TotalLeadershipRequirementValue, Stats.RequiredLeadershipReduction }, + }; + + /// + /// Gets the maximum durability of the item. + /// + /// The item. + /// The maximum durability of the item. + /// + /// I think this is more like the durability which can be dropped. + /// Some items can be stacked up to 255 pieces, which increases the durability value. + /// + public static byte GetMaximumDurabilityOfOnePiece(this Item item) + { + if (!item.IsWearable()) + { + // Items which are not wearable don't have a "real" durability. If the item is stackable, durability means number of pieces in this case + return 1; + } + + if (item.IsTrainablePet()) + { + return 255; + } + + var result = item.Definition!.Durability + AdditionalDurabilityPerLevel[item.Level]; + if (item.IsAncient()) + { + result += 20; + } + else if (item.IsExcellent()) + { + // TODO: archangel weapons, but I guess it's not a big issue if we don't, because of their already high durability + result += 15; + } + else + { + // there are no other options which increase the durability. + // It might be nice to add the magic values above to the ItemOptionType, as data. + } + + return (byte)Math.Min(byte.MaxValue, result); + } + + /// + /// Determines whether this instance is ancient. + /// + /// The item. + /// + /// true if the specified item is ancient; otherwise, false. + /// + public static bool IsAncient(this Item item) + { + return item.ItemSetGroups.Any(itemSet => itemSet.AncientSetDiscriminator > 0); + } + + /// + /// Determines whether this instance is excellent. + /// + /// The item. + /// + /// true if the specified item is excellent; otherwise, false. + /// + public static bool IsExcellent(this Item item) + { + return item.ItemOptions.Any(link => link.ItemOption?.OptionType == ItemOptionTypes.Excellent); + } + + /// + /// Determines whether this instance is a shield. + /// + /// The item. + /// + /// true if the specified item is a shield; otherwise, false. + /// + public static bool IsShield(this Item item) + { + return item.Definition?.Group == ShieldItemGroup; + } + + /// + /// Determines whether this item is a jewelry (pendant or ring) item. + /// + /// The item. + /// + /// true if the specified item is jewelry; otherwise, false. + /// + public static bool IsJewelry(this Item item) + { + return item.ItemSlot >= InventoryConstants.PendantSlot && item.ItemSlot <= InventoryConstants.Ring2Slot; + } + + /// + /// Determines whether this item is a transformation ring. + /// + /// The item. + /// + /// true if the specified item is a transformation ring; otherwise, false. + /// + public static bool IsTransformationRing(this Item item) + { + return item.Definition?.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.TransformationSkin) ?? false; + } + + /// + /// Determines whether this item is an armor item. + /// + /// The item. + /// + /// true if the specified item is armor; otherwise, false. + /// + public static bool IsArmorItem(this Item item) + { + return item.Definition?.ItemSlot?.ItemSlots.Any(slot => + slot >= InventoryConstants.HelmSlot && slot <= InventoryConstants.BootsSlot) ?? false; + } + + /// + /// Determines whether this instance is a is weapon which deals physical damage. + /// + /// The item. + /// The minimum physical damage of the weapon. + /// + /// true if this instance is a weapon which deals physical damage; otherwise, false. + /// + public static bool IsPhysicalWeapon(this Item item, [NotNullWhen(true)] out float? minimumDmg) + { + minimumDmg = item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.MinimumPhysBaseDmgByWeapon)?.BaseValue; + return minimumDmg is not null; + } + + /// + /// Determines whether this instance is a weapon which increases wizardry damage. + /// + /// The item. + /// The staff/sword/stick's wizardry damage rise percentage. + /// + /// true if this instance is a weapon which increases wizardry damage; otherwise, false. + /// + public static bool IsWizardryWeapon(this Item item, [NotNullWhen(true)] out float? staffRise) + { + staffRise = item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.StaffRise)?.BaseValue; + return staffRise is not null; + } + + /// + /// Determines whether this instance is a scepter which increases raven damage. + /// + /// The item. + /// The scepter's pet attack rise percentage. + /// + /// true if this instance is a scepter which increases raven damage; otherwise, false. + /// + public static bool IsScepter(this Item item, [NotNullWhen(true)] out float? scepterRise) + { + scepterRise = item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.ScepterRise)?.BaseValue; + return scepterRise is not null; + } + + /// + /// Determines whether this instance is a book which increases curse damage. + /// + /// The item. + /// The book's curse damage rise percentage. + /// + /// true if this instance is a book which increases curse damage; otherwise, false. + /// + public static bool IsBook(this Item item, [NotNullWhen(true)] out float? bookRise) + { + bookRise = item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.BookRise)?.BaseValue; + return bookRise is not null; + } + + /// + /// Determines whether this instance's power-ups depend on its current durability. + /// + /// The item. + /// + /// true if this instance's power-ups are subject to a durabiity factor; otherwise, false. + /// + public static bool DegradesWithDurability(this Item item) + { + return item.ItemSlot != InventoryConstants.PetSlot && !item.IsTrainablePet() && !item.IsTransformationRing(); + } + + /// + /// Gets the requirement as a tuple of an attribute and the corresponding value. + /// + /// The item. + /// The requirement. + /// The requirement as a tuple of an attribute and the corresponding value. + /// + /// Some requirements are depending on item level, drop level and item options. + /// + public static (AttributeDefinition Attr, int Value) GetRequirement(this Item item, AttributeRequirement requirement) + { + requirement.ThrowNotInitializedProperty(requirement.Attribute is null, nameof(requirement.Attribute)); + + var minimumValue = requirement.MinimumItemLevel is { } minimumItemLevel && item.Level < minimumItemLevel + ? 0 + : requirement.MinimumValue; + + if (RequirementAttributeMapping.TryGetValue(requirement.Attribute, out var totalAttribute)) + { + if (!item.IsWearable()) + { + return (totalAttribute, minimumValue); + } + + var multiplier = 3; + if (totalAttribute == Stats.TotalEnergy) + { + multiplier = 4; + + // Summoner books, which increase the curse damage by their book rise, are calculated differently. + if (item.IsBook(out _)) + { + return (totalAttribute, item.CalculateBookEnergyRequirement(minimumValue)); + } + } + + var value = item.CalculateRequirement(minimumValue, multiplier); + if (value > 0 && totalAttribute == Stats.TotalStrength) + { + var itemOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option); + if (itemOption != null) + { + value += itemOption.Level * (item.IsArmorItem() ? 5 : 4); + } + } + + if (RequirementReductionAttributeMapping.TryGetValue(requirement.Attribute, out var reductionAttribute) + && item.ItemOptions.FirstOrDefault(o => + o.ItemOption?.PowerUpDefinition?.TargetAttribute == reductionAttribute + || (o.ItemOption?.LevelDependentOptions.Any(l => l.PowerUpDefinition?.TargetAttribute == reductionAttribute) ?? false)) + is { } reductionOption) + { + var optionOfLevelPowerUp = reductionOption.ItemOption!.LevelDependentOptions + .FirstOrDefault(o => o.Level == reductionOption.Level)?.PowerUpDefinition + ?? reductionOption.ItemOption.PowerUpDefinition; + if (optionOfLevelPowerUp?.Boost?.ConstantValue is { } reduction) + { + value -= (int)reduction.Value; + } + } + + return (totalAttribute, value); + } + + return (requirement.Attribute, minimumValue); + } + + /// + /// Calculates the drop level. + /// + /// The item. + /// A value indicating whether the item is ancient. + /// A value indicating whether the item is excellent. + /// The item level. + /// The calculated drop level. + public static int CalculateDropLevel(this ItemDefinition item, bool isAncient, bool isExcellent, int itemLevel) + { + int dropLevel = item.DropLevel; + if (isAncient) + { + dropLevel += 30; + } + else if (isExcellent) + { + dropLevel += 25; + } + else + { + // nothing to add + } + + dropLevel += 3 * itemLevel; + return dropLevel; + } + + /// + /// Gets the current durability factor for the item. + /// + /// The item. + /// + /// Directly related to the client's UI item durability icons, whose colors span yellow (<50%), orange (<30%), light red (<20%) and red (exhausted). + /// + /// The factor. + public static float GetCurrentDurabilityFactor(this Item item) + { + if (item.Durability == 0) + { + return 0; + } + + if (!item.DegradesWithDurability()) + { + return 1; + } + + var maxDurability = item.GetMaximumDurabilityOfOnePiece(); + for (int i = 0; i < DurabilityFactors.Length; i++) + { + if (item.Durability < maxDurability * DurabilityFactors[i]) + { + return 1 - DurabilityFactors[^(i + 1)]; + } + } + + return 1; + } + + /// + /// Determines whether the option is a fixed option, which is a normal option without level dependent values. + /// + /// + /// Several fixed options (e.g. of the Horn of Dinorant) can be on one item. + /// The client receives them in the field of the normal option level, where + /// the of each fixed option is its bit. + /// + /// The option. + /// true, if the option is a fixed option; otherwise, false. + public static bool IsFixedOption(this IncreasableItemOption option) + { + return option.OptionType == ItemOptionTypes.Option && option.LevelDependentOptions is not { Count: > 0 }; + } + + private static int CalculateRequirement(this Item item, int requirementValue, int multiplier) + { + if (requirementValue == 0) + { + return 0; + } + + var dropLevel = item.Definition!.CalculateDropLevel(item.IsAncient(), item.IsExcellent(), item.Level); + + return (multiplier * dropLevel * requirementValue / 100) + 20; + } + + private static int CalculateBookEnergyRequirement(this Item item, int energyRequirementValue) + { + var dropLevel = item.Definition!.CalculateDropLevel(item.IsAncient(), item.IsExcellent(), 0); + + return (((energyRequirementValue * (dropLevel + item.Level)) * 3) / 100) + 20; + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs new file mode 100644 index 0000000000..bbf770f59e --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs @@ -0,0 +1,357 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; + +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Attributes; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// A frozen copy of src/GameLogic/ItemPowerUpFactory.cs at commit 90a571100, +/// which compares with the current code. +/// Change it only together with an intentional change of the formula, see the remarks of the test. +/// +/// +/// Lines 17-101 and 218-455 are copied unchanged, except the class name in lines 30, 33 and 36, and the documentation of +/// GetPowerUps (line 41), which took it from the interface the copy doesn't implement, so that a change of the live +/// interface doesn't change the copy. The set power-ups (lines 103-216) are left out: the test doesn't compare them, +/// and the item formulas aren't part of them. +/// The item checks, the drop level, the durability factor and IsFixedOption come from , +/// which also names the live code the copies still call. +/// +internal class LegacyItemPowerUpFactory +{ + private static readonly HashSet DurabilityAffectedItemAttributes = + [ + Stats.DefenseBase, + Stats.DefenseShield, + Stats.DefenseRatePvm, + Stats.MinimumPhysBaseDmgByWeapon, + Stats.MaximumPhysBaseDmgByWeapon, + Stats.StaffRise, + Stats.ScepterRise, + Stats.BookRise, + ]; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public LegacyItemPowerUpFactory(ILogger logger) + { + this._logger = logger; + } + + /// + /// Gets the power ups of an individual item. + /// + /// The item. + /// The attribute system of the player who equipped the item. + /// The created power ups. + public IEnumerable GetPowerUps(Item item, AttributeSystem attributeHolder) + { + if (item.Definition is null) + { + this._logger.LogWarning("Item of slot {itemSlot} ({itemId}) has no definition.", item.ItemSlot, item.GetId()); + yield break; + } + + if (item.Durability <= 0) + { + yield break; + } + + if (item.ItemSlot < InventoryConstants.FirstEquippableItemSlotIndex || item.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex) + { + yield break; + } + + var isRightWieldWeapon = item.ItemSlot == InventoryConstants.RightHandSlot + && item.Definition!.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.DoubleWieldWeaponCount || pu.TargetAttribute == Stats.IsOneHandedStaffEquipped); + + AttributeDefinition? targetAttribute = null; + foreach (var attribute in item.Definition.BasePowerUpAttributes) + { + if (isRightWieldWeapon) + { + if (attribute.TargetAttribute == Stats.StaffRise) + { + continue; + } + else if (attribute.TargetAttribute == Stats.MinimumPhysBaseDmgByWeapon) + { + targetAttribute = Stats.MinPhysBaseDmgByRightWeapon; + } + else if (attribute.TargetAttribute == Stats.MaximumPhysBaseDmgByWeapon) + { + targetAttribute = Stats.MaxPhysBaseDmgByRightWeapon; + } + else + { + targetAttribute = null; + } + } + + foreach (var powerUp in this.GetBasePowerUpWrappers(item, attributeHolder, attribute, targetAttribute)) + { + yield return powerUp; + } + } + + foreach (var powerUp in this.GetPowerUpsOfItemOptions(item, attributeHolder)) + { + yield return powerUp; + } + + if (this.GetPetLevel(item, attributeHolder) is { } petLevel) + { + yield return petLevel; + } + } + + private IEnumerable GetBasePowerUpWrappers(Item item, AttributeSystem attributeHolder, ItemBasePowerUpDefinition attribute, AttributeDefinition? targetAttribute = null) + { + attribute.ThrowNotInitializedProperty(attribute.TargetAttribute is null, nameof(attribute.TargetAttribute)); + + var durabilityFactor = DurabilityAffectedItemAttributes.Contains(attribute.TargetAttribute) ? item.GetCurrentDurabilityFactor() : 1; + var baseValueElmt = attribute.GetBaseValueElement(durabilityFactor); + + var levelBonusElmt = (attribute.BonusPerLevelTable?.BonusPerLevel ?? Enumerable.Empty()) + .FirstOrDefault(bonus => bonus.Level == item.Level)? + .GetAdditionalValueElement(attribute.AggregateType, durabilityFactor); + if (levelBonusElmt is null) + { + yield return new PowerUpWrapper(baseValueElmt, targetAttribute ?? attribute.TargetAttribute, attributeHolder); + } + else + { + yield return new PowerUpWrapper( + new CombinedElement(baseValueElmt, levelBonusElmt), + targetAttribute ?? attribute.TargetAttribute, + attributeHolder); + } + } + + private IEnumerable GetPowerUpsOfItemOptions(Item item, AttributeSystem attributeHolder) + { + var options = item.ItemOptions; + if (options is null) + { + yield break; + } + + foreach (var optionLink in options) + { + var option = optionLink.ItemOption; + if (option is null) + { + this._logger.LogWarning("Item {item} (id {itemId}) has ItemOptionLink ({optionLinkId}) without option.", item, item.GetId(), optionLink.GetId()); + continue; + } + + if (item.ItemSlot == InventoryConstants.RightHandSlot + && option.OptionType == ItemOptionTypes.Option + && option.PowerUpDefinition?.TargetAttribute == Stats.WizardryBaseDmg) + { + // For a RH-wielded staff (MG), its wizardry item option doesn't count (but the others do!) + continue; + } + + var level = option.LevelType == LevelType.ItemLevel ? item.Level : optionLink.Level; + + var optionOfLevel = option.LevelDependentOptions?.FirstOrDefault(l => l.Level == level); + + // Fixed options (e.g. of the Dinorant) have no level dependent values, so their level doesn't matter. + if (optionOfLevel is null && level > 1 && !option.IsFixedOption()) + { + this._logger.LogWarning("Item {item} (id {itemId}) has IncreasableItemOption ({option}, id {optionId}) with level {level}, but no definition in LevelDependentOptions.", item, item.GetId(), option, option.GetId(), level); + continue; + } + + if (optionOfLevel?.RequiredItemLevel > item.Level) + { + // Some options (like harmony) although on the item can be inactive. + continue; + } + + var powerUp = optionOfLevel?.PowerUpDefinition ?? option.PowerUpDefinition; + + if (powerUp?.Boost is null) + { + // Some options are level dependent. If they are at level 0, they might not have any boost yet. + continue; + } + + AggregateType? aggregateType = null; + if (option.OptionType == ItemOptionTypes.Excellent) + { + if (item.ItemSlot == InventoryConstants.PendantSlot + && option.PowerUpDefinition?.TargetAttribute == Stats.PhysicalBaseDmg) + { + // Pendant options are not subject to double wield averaging + aggregateType = AggregateType.AddFinal; + } + else if (option.PowerUpDefinition?.TargetAttribute == Stats.PhysicalBaseDmgIncrease + && item.Definition!.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.DoubleWieldWeaponCount)) + { + // This needs special treatment, since this option is averaged when double wielding + aggregateType = AggregateType.AddRaw; + } + else + { + // the normal aggregate type should be used + } + } + + var durabilityFactor = option.OptionType == ItemOptionTypes.AncientBonus || + (option.OptionType == ItemOptionTypes.Option && powerUp.TargetAttribute != Stats.HealthRecoveryMultiplier && !item.IsJewelry()) + ? item.GetCurrentDurabilityFactor() : 1.0f; + foreach (var wrapper in PowerUpWrapper.CreateByPowerUpDefinition(powerUp, attributeHolder, aggregateType, durabilityFactor)) + { + yield return wrapper; + } + } + + foreach (var powerUpWrapper in this.CreateExcellentAndAncientBasePowerUpWrappers(item, attributeHolder)) + { + yield return powerUpWrapper; + } + } + + // TODO: Make this more generic and configurable? + private IEnumerable CreateExcellentAndAncientBasePowerUpWrappers(Item item, AttributeSystem attributeHolder) + { + var itemIsExcellent = item.IsExcellent(); + var itemIsAncient = item.IsAncient(); + + if (!itemIsAncient && !itemIsExcellent) + { + yield break; + } + + var baseDropLevel = item.Definition!.DropLevel; + var ancientDropLevel = item.Definition!.CalculateDropLevel(true, false, 0); + var durabilityFactor = item.GetCurrentDurabilityFactor(); + + if (item.IsArmorItem()) + { + var baseDefense = (int)(item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.DefenseBase)?.BaseValue ?? 0); + var additionalDefense = (baseDefense * 12 / baseDropLevel) + (baseDropLevel / 5) + 4; + yield return new PowerUpWrapper(new SimpleElement(additionalDefense * durabilityFactor, AggregateType.AddRaw), Stats.DefenseBase, attributeHolder); + if (itemIsAncient) + { + var ancientDefenseBonus = 2 + ((baseDefense + additionalDefense) * 3 / ancientDropLevel) + (ancientDropLevel / 30); + yield return new PowerUpWrapper(new SimpleElement(ancientDefenseBonus * durabilityFactor, AggregateType.AddRaw), Stats.DefenseBase, attributeHolder); + } + } + + if (item.IsShield()) + { + var baseDefenseRate = (int)(item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.DefenseRatePvm)?.BaseValue ?? 0); + var additionalRate = (baseDefenseRate * 25 / baseDropLevel) + 5; + yield return new PowerUpWrapper(new SimpleElement(additionalRate * durabilityFactor, AggregateType.AddRaw), Stats.DefenseRatePvm, attributeHolder); + if (itemIsAncient) + { + var baseDefense = (int)(item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.DefenseShield)?.BaseValue ?? 0); + var ancientDefenseBonus = 2 + ((baseDefense + item.Level) * 20 / ancientDropLevel); + yield return new PowerUpWrapper(new SimpleElement(ancientDefenseBonus * durabilityFactor, AggregateType.AddRaw), Stats.DefenseShield, attributeHolder); + } + } + + if (item.IsPhysicalWeapon(out var minPhysDmg)) + { + var minDmgAttribute = Stats.MinimumPhysBaseDmgByWeapon; + var maxDmgAttribute = Stats.MaximumPhysBaseDmgByWeapon; + if (item.ItemSlot == InventoryConstants.RightHandSlot + && item.Definition!.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.DoubleWieldWeaponCount || pu.TargetAttribute == Stats.IsOneHandedStaffEquipped)) + { + minDmgAttribute = Stats.MinPhysBaseDmgByRightWeapon; + maxDmgAttribute = Stats.MaxPhysBaseDmgByRightWeapon; + } + + var additionalDmg = ((int)minPhysDmg * 25 / baseDropLevel) + 5; + yield return new PowerUpWrapper(new SimpleElement(additionalDmg * durabilityFactor, AggregateType.AddRaw), minDmgAttribute, attributeHolder); + yield return new PowerUpWrapper(new SimpleElement(additionalDmg * durabilityFactor, AggregateType.AddRaw), maxDmgAttribute, attributeHolder); + if (itemIsAncient) + { + var ancientBonus = 5 + (ancientDropLevel / 40); + yield return new PowerUpWrapper(new SimpleElement(ancientBonus * durabilityFactor, AggregateType.AddRaw), minDmgAttribute, attributeHolder); + yield return new PowerUpWrapper(new SimpleElement(ancientBonus * durabilityFactor, AggregateType.AddRaw), maxDmgAttribute, attributeHolder); + } + + if (itemIsExcellent + && item.ItemOptions.Any(io => io.ItemOption?.PowerUpDefinition?.TargetAttribute == Stats.PhysicalBaseDmgIncrease) + && item.Definition!.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.DoubleWieldWeaponCount)) + { + yield return new PowerUpWrapper(new SimpleElement(-1, AggregateType.AddRaw), Stats.PhysicalBaseDmgIncrease, attributeHolder); + } + } + + if (item.IsWizardryWeapon(out var staffRise) && item.ItemSlot == InventoryConstants.LeftHandSlot) + { + var additionalRise = (((int)staffRise * 2 * 25 / baseDropLevel) + 5) / 2; + yield return new PowerUpWrapper(new SimpleElement(additionalRise * durabilityFactor, AggregateType.AddRaw), Stats.StaffRise, attributeHolder); + if (itemIsAncient) + { + var ancientRiseBonus = (2 + (ancientDropLevel / 60)) / 2; + yield return new PowerUpWrapper(new SimpleElement(ancientRiseBonus * durabilityFactor, AggregateType.AddRaw), Stats.StaffRise, attributeHolder); + } + } + + if (item.IsScepter(out var scepterRise)) + { + var additionalRise = (((int)scepterRise * 2 * 25 / baseDropLevel) + 5) / 2; + yield return new PowerUpWrapper(new SimpleElement(additionalRise * durabilityFactor, AggregateType.AddRaw), Stats.ScepterRise, attributeHolder); + if (itemIsAncient) + { + var ancientRiseBonus = (2 + (ancientDropLevel / 60)) / 2; + yield return new PowerUpWrapper(new SimpleElement(ancientRiseBonus * durabilityFactor, AggregateType.AddRaw), Stats.ScepterRise, attributeHolder); + } + } + + if (item.IsBook(out var curseRise)) + { + var additionalRise = (((int)curseRise * 2 * 25 / baseDropLevel) + 5) / 2; + yield return new PowerUpWrapper(new SimpleElement(additionalRise * durabilityFactor, AggregateType.AddRaw), Stats.BookRise, attributeHolder); + if (itemIsAncient) + { + var ancientRiseBonus = (2 + (ancientDropLevel / 60)) / 2; + yield return new PowerUpWrapper(new SimpleElement(ancientRiseBonus * durabilityFactor, AggregateType.AddRaw), Stats.BookRise, attributeHolder); + } + } + + if (itemIsAncient && item.IsJewelry()) + { + foreach (var baseAttribute in item.Definition!.BasePowerUpAttributes) + { + if (Stats.ElementResistanceToDamageBonus.Keys.FirstOrDefault(resistance => resistance == baseAttribute.TargetAttribute) is { } key) + { + yield return new PowerUpWrapper(new SimpleElement(5 * durabilityFactor, AggregateType.AddRaw), Stats.ElementResistanceToDamageBonus[key], attributeHolder); + } + } + } + } + + private PowerUpWrapper? GetPetLevel(Item item, AttributeSystem attributeHolder) + { + const byte darkHorseNumber = 4; + + if (!item.IsTrainablePet()) + { + return null; + } + + return new PowerUpWrapper( + new SimpleElement(item.Level, AggregateType.AddRaw), + item.Definition?.Number == darkHorseNumber ? Stats.HorseLevel : Stats.RavenLevel, + attributeHolder); + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs new file mode 100644 index 0000000000..2f587f509d --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs @@ -0,0 +1,583 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; + +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// A frozen copy of src/GameLogic/ItemPriceCalculator.cs at commit 90a571100, +/// which compares with the current code. +/// Change it only together with an intentional change of the formula, see the remarks of the test. +/// +/// +/// Lines 19-581 are copied unchanged. The maximum durability comes from . +/// +internal class LegacyItemPriceCalculator +{ + private const short ForceWaveSkillId = 66; + private const short ExplosionSkillId = 223; + private const short RequiemSkillId = 224; + private const short PollutionSkillId = 225; + private const long MaximumPrice = 3_000_000_000; + private const float DestroyedPetPenalty = 2.0f; + private const float DestroyedItemPenalty = 1.4f; + + private static readonly List WorthlessSkills = [ForceWaveSkillId, ExplosionSkillId, RequiemSkillId, PollutionSkillId]; + + private static readonly Dictionary DropLevelIncreaseByLevel = new() + { + { 5, 4 }, + { 6, 10 }, + { 7, 25 }, + { 8, 45 }, + { 9, 65 }, + { 10, 95 }, + { 11, 135 }, + { 12, 185 }, + { 13, 245 }, + { 14, 305 }, + { 15, 365 }, + }; + + private static readonly IDictionary SpecialItemOldValueDictionary = new Dictionary + { + { (int)SpecialItems.Bless, 100_000 }, + { (int)SpecialItems.Soul, 70_000 }, + { (int)SpecialItems.Chaos, 40_000 }, + { (int)SpecialItems.Life, 450_000 }, + { (int)SpecialItems.Creation, 450_000 }, + }; + + private static readonly IDictionary> SpecialItemDictionary = new Dictionary> + { + { + (int)SpecialItems.Arrow, item => + { + int gold = 0; + int baseprice = item.Level switch + { + 1 => 1200, + 2 => 2000, + 3 => 2800, + _ => 70, + }; + + if (item.Durability > 0) + { + gold = baseprice * item.Durability() / item.Definition?.Durability ?? 1; + } + + return gold; + } + }, + { + (int)SpecialItems.Bolt, item => + { + int gold = 0; + int baseprice = item.Level switch + { + 1 => 1400, + 2 => 2200, + 3 => 3000, + _ => 100, + }; + + if (item.Durability > 0) + { + gold = baseprice * item.Durability() / item.Definition?.Durability ?? 1; + } + + return gold; + } + }, + { (int)SpecialItems.Bless, _ => 9000000 }, + { (int)SpecialItems.Soul, _ => 6000000 }, + { (int)SpecialItems.Chaos, _ => 810000 }, + { (int)SpecialItems.Life, _ => 45000000 }, + { (int)SpecialItems.Creation, _ => 36000000 }, + { (int)SpecialItems.Guardian, _ => 60000000 }, + { (int)SpecialItems.Gemstone, _ => 18600 }, + { (int)SpecialItems.Harmony, _ => 18600 }, + { (int)SpecialItems.LowerRefineStone, _ => 18600 }, + { (int)SpecialItems.HigherRefineStone, _ => 18600 }, + { (int)SpecialItems.PackedBless, item => (item.Level + 1) * 9000000 * 10 }, + { (int)SpecialItems.PackedSoul, item => (item.Level + 1) * 6000000 * 10 }, + { (int)SpecialItems.PackedChaos, item => (item.Level + 1) * 810000 * 10 }, + { (int)SpecialItems.PackedLife, item => (item.Level + 1) * 45000000 * 10 }, + { (int)SpecialItems.PackedCreation, item => (item.Level + 1) * 36000000 * 10 }, + { (int)SpecialItems.PackedGuardian, item => (item.Level + 1) * 60000000 * 10 }, + { (int)SpecialItems.PackedGemstone, item => (item.Level + 1) * 18600 * 10 }, + { (int)SpecialItems.PackedHarmony, item => (item.Level + 1) * 18600 * 10 }, + { (int)SpecialItems.PackedLowerRefineStone, item => (item.Level + 1) * 18600 * 10 }, + { (int)SpecialItems.PackedHigherRefineStone, item => (item.Level + 1) * 18600 * 10 }, + { (int)SpecialItems.Fruits, _ => 33000000 }, + { (int)SpecialItems.LochFeather, item => item.Level == 1 ? 7500000 : 180000 }, + { (int)SpecialItems.SiegePotion, item => item.Durability() * (item.Level == 0 ? 900000 : 450000) }, + { (int)SpecialItems.OrderGuardianLifeStone, item => item.Level == 1 ? 2400000 : 1000000 }, + { (int)SpecialItems.ContractSummon, item => item.Level == 0 ? 1500000 : item.Level == 1 ? 1200000 : 0 }, + { (int)SpecialItems.SplinterOfArmor, item => item.Durability() * 150 }, + { (int)SpecialItems.BlessOfGuardian, item => item.Durability() * 300 }, + { (int)SpecialItems.ClawOfBeast, item => item.Durability() * 3000 }, + { (int)SpecialItems.FragmentOfHorn, _ => 30000 }, + { (int)SpecialItems.BrokenHorn, _ => 90000 }, + { (int)SpecialItems.HornFenrir, _ => 150000 }, + { (int)SpecialItems.SmallSdPotion, item => item.Durability() * 2000 }, + { (int)SpecialItems.SdPotion, item => item.Durability() * 4000 }, + { (int)SpecialItems.LargeSdPotion, item => item.Durability() * 6000 }, + { (int)SpecialItems.LargeHealPotion, item => item.Durability() * 1500 * (item.Level + 1) }, + { (int)SpecialItems.LargeManaPotion, item => item.Durability() * 1500 * (item.Level + 1) }, + { (int)SpecialItems.SmallComplexPotion, item => item.Durability() * 2500 }, + { (int)SpecialItems.ComplexPotion, item => item.Durability() * 5000 }, + { (int)SpecialItems.LargeComplexPotion, item => item.Durability() * 7500 }, + { + (int)SpecialItems.Dinorant, item => + { + var opts = item.ItemOptions.Where(o => o.ItemOption?.OptionType == ItemOptionTypes.Option).Count(); + return 960000 + (300000 * opts); + } + }, + { + (int)SpecialItems.DevilEye, item => item.Level == 1 ? 10000 : + item.Level == 2 ? 50000 : + item.Level == 3 ? 100000 : + item.Level == 4 ? 300000 : + item.Level == 5 ? 500000 : + item.Level == 6 ? 800000 : + item.Level == 7 ? 1000000 : 10000 + }, + { + (int)SpecialItems.DevilKey, item => item.Level == 1 ? 15000 : + item.Level == 2 ? 75000 : + item.Level == 3 ? 150000 : + item.Level == 4 ? 450000 : + item.Level == 5 ? 750000 : + item.Level == 6 ? 1200000 : + item.Level == 7 ? 1500000 : 15000 + }, + { (int)SpecialItems.DevilInvitation, item => item.Level is 1 ? 60000 : item.Level == 2 ? 84000 : (item.Level - 1) * 60000 }, // +7 sell price on S6E3 client is 60k (same as +4). Bug? + { (int)SpecialItems.RemedyOfLove, _ => 900 }, + { (int)SpecialItems.Rena, item => item.Level == 3 ? item.Durability() * 3900 : 9000 }, + { (int)SpecialItems.Ale, _ => 750 }, + { (int)SpecialItems.InvisibleCloak, item => item.Level == 1 ? 150000 : 600000 + ((item.Level - 1) * 60000) }, + { + (int)SpecialItems.ScrollOfArchangel, item => item.Level == 1 ? 10000 : + item.Level == 2 ? 50000 : + item.Level == 3 ? 100000 : + item.Level == 4 ? 300000 : + item.Level == 5 ? 500000 : + item.Level == 6 ? 800000 : + item.Level == 7 ? 1000000 : + item.Level == 8 ? 1200000 : 10000 + }, + { + (int)SpecialItems.BloodBone, item => item.Level == 1 ? 10000 : + item.Level == 2 ? 50000 : + item.Level == 3 ? 100000 : + item.Level == 4 ? 300000 : + item.Level == 5 ? 500000 : + item.Level == 6 ? 800000 : + item.Level == 7 ? 1000000 : + item.Level == 8 ? 1200000 : 10000 + }, + { (int)SpecialItems.OldScroll, item => item.Level == 1 ? 500000 : (item.Level + 1) * 200000 }, + { (int)SpecialItems.IllusionSorcererCovenant, item => item.Level == 1 ? 500000 : (item.Level + 1) * 200000 }, + { (int)SpecialItems.ScrollOfBlood, item => item.Level == 1 ? 500000 : (item.Level + 1) * 200000 }, + { (int)SpecialItems.FlameOfCondor, _ => 3000000 }, + { (int)SpecialItems.FeatherOfCondor, _ => 3000000 }, + { (int)SpecialItems.ArmorGuardman, _ => 5000 }, + { (int)SpecialItems.WizardsRing, item => item.Level == 0 ? 30000 : 0 }, + { (int)SpecialItems.SpiritPet, item => item.Level == 0 ? 30000000 : item.Level == 1 ? 15000000 : 0 }, + { (int)SpecialItems.LostMap, _ => 600000 }, + { (int)SpecialItems.SymbolKundun, item => 30000 * item.Durability() }, + { (int)SpecialItems.Halloween1, item => 150 * item.Durability() }, + { (int)SpecialItems.Halloween2, item => 150 * item.Durability() }, + { (int)SpecialItems.Halloween3, item => 150 * item.Durability() }, + { (int)SpecialItems.Halloween4, item => 150 * item.Durability() }, + { (int)SpecialItems.Halloween5, item => 150 * item.Durability() }, + { (int)SpecialItems.Halloween6, item => 150 * item.Durability() }, + { (int)SpecialItems.GemOfSecret, item => item.Level == 0 ? 60000 : 0 }, + { (int)SpecialItems.SuspiciousScrapOfPaper, item => 30000 * item.Durability() }, + { (int)SpecialItems.GaionsOrder, item => 30000 * item.Durability() }, + { (int)SpecialItems.FirstSecromiconFragment, item => 30000 * item.Durability() }, + { (int)SpecialItems.SecondSecromiconFragment, item => 30000 * item.Durability() }, + { (int)SpecialItems.ThirdSecromiconFragment, item => 30000 * item.Durability() }, + { (int)SpecialItems.FourthSecromiconFragment, item => 30000 * item.Durability() }, + { (int)SpecialItems.FifthSecromiconFragment, item => 30000 * item.Durability() }, + { (int)SpecialItems.SixthSecromiconFragment, item => 30000 * item.Durability() }, + { (int)SpecialItems.CompleteSecromicon, item => 30000 * item.Durability() }, + { (int)SpecialItems.ChristmasStar, _ => 200000 }, + { (int)SpecialItems.Firecracker, _ => 200000 }, + { (int)SpecialItems.CherryBlossomWine, item => 300 * item.Durability() }, + { (int)SpecialItems.CherryBlossomRiceCake, item => 300 * item.Durability() }, + { (int)SpecialItems.CherryBlossomFlowerPetal, item => 300 * item.Durability() }, + { (int)SpecialItems.GoldenCherryBlossomBranch, item => 300 * item.Durability() }, + }; + + private enum SpecialItems + { + Arrow = 0xF04, // getId(4, 15), + Bolt = 0x704, // getId(4, 7), + Bless = 0xD0E, // getId(14,13), + Soul = 0xE0E, // getId(14,14), + Chaos = 0xF0C, // getId(12,15), + Life = 0x100E, // getId(14,16), + Creation = 0x160E, // getId(14,22), + Guardian = 0x1F0E, // getId(14,31), + Gemstone = 0x290E, + Harmony = 0x2A0E, + LowerRefineStone = 0x2B0E, + HigherRefineStone = 0x2C0E, + PackedBless = 0x1E0C, // getId(12,30), + PackedSoul = 0x1F0C, // getId(12,31), + PackedChaos = 0x8D0C, + PackedLife = 0x880C, + PackedCreation = 0x890C, + PackedGuardian = 0x8A0C, + PackedGemstone = 0x8B0C, + PackedHarmony = 0x8C0C, + PackedLowerRefineStone = 0x8E0C, + PackedHigherRefineStone = 0x8F0C, + Fruits = 0xF0D, // getId(13,15), + LochFeather = 0xE0D, // getId(13,14), + LargeHealPotion = 0x030E, + LargeManaPotion = 0x060E, + SiegePotion = 0x70E, // getId(14,7), + OrderGuardianLifeStone = 0xB0D, // getId(13,11), + ContractSummon = 0x70D, // getId(13,7), + SplinterOfArmor = 0x200D, // getId(13,32), + BlessOfGuardian = 0x210D, // getId(13,33), + ClawOfBeast = 0x220D, // getId(13,34), + FragmentOfHorn = 0x230D, // getId(13,35), + BrokenHorn = 0x240D, // getId(13,36), + HornFenrir = 0x250D, // getId(13,37), + SmallSdPotion = 0x230E, // getId(14,35), + SdPotion = 0x240E, // getId(14, 36), + LargeSdPotion = 0x250E, // getId(14,37), + SmallComplexPotion = 0x260E, // getId(14,38), + ComplexPotion = 0x270E, // getId(14,39), + LargeComplexPotion = 0x280E, // getId(14,40), + Dinorant = 0x30D, // getId(13,3), + DevilEye = 0x110E, // getId(14,17), + DevilKey = 0x120E, // getId(14,18), + DevilInvitation = 0x130E, // getId(14,19), + RemedyOfLove = 0x140E, // getId(14,20), + Rena = 0x150E, // getId(14,21), + Ale = 0x90E, // getId(14,9), + InvisibleCloak = 0x120D, // getId(13,18), + ScrollOfArchangel = 0x100D, // getId(13, 16), + BloodBone = 0x110D, // getId(13,17), + ArmorGuardman = 0x1D0D, // getId(13,29), + WizardsRing = 0x140D, // getId(13,20), + SpiritPet = 0x1F0D, // getId(13,31), + LostMap = 0x1C0E, // getId(14,28), + SymbolKundun = 0x1D0E, // getId(14,29), + Halloween1 = 0x2D0E, // getId(14, 45), + Halloween2 = 0x2E0E, // getId(14, 46), + Halloween3 = 0x2F0E, // getId(14, 47), + Halloween4 = 0x300E, // getId(14, 48), + Halloween5 = 0x310E, // getId(14, 49), + Halloween6 = 0x320E, // getId(14, 50), + GemOfSecret = 0x1A0C, // getId(12,26), + OldScroll = 0x310D, + IllusionSorcererCovenant = 0x320D, + ScrollOfBlood = 0x330D, + FlameOfCondor = 0x340D, + FeatherOfCondor = 0x350D, + SuspiciousScrapOfPaper = 0x650E, + GaionsOrder = 0x660E, + FirstSecromiconFragment = 0x670E, + SecondSecromiconFragment = 0x680E, + ThirdSecromiconFragment = 0x690E, + FourthSecromiconFragment = 0x6A0E, + FifthSecromiconFragment = 0x6B0E, + SixthSecromiconFragment = 0x6C0E, + CompleteSecromicon = 0x6D0E, + ChristmasStar = 0x330E, + Firecracker = 0x3F0E, + CherryBlossomWine = 0x550E, + CherryBlossomRiceCake = 0x560E, + CherryBlossomFlowerPetal = 0x570E, + GoldenCherryBlossomBranch = 0x5A0E, + } + + /// + /// Calculates the selling price of the item for its maximum durability. + /// + /// The item. + /// The selling price. + public long CalculateSellingPrice(Item item) => this.CalculateSellingPrice(item, item.GetMaximumDurabilityOfOnePiece()); + + /// + /// Calculates the selling price of the item, which the player gets if he is selling an item to a merchant. + /// It's usually a third of the buying price, minus a durability factor. + /// + /// The item. + /// The current durability of the . + /// The selling price. + public long CalculateSellingPrice(Item item, byte durability) + { + item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition)); + + var sellingPrice = CalculateBuyingPrice(item) / 3; + if (item.Definition.Group == 14 && item.Definition.Number <= 8) + { + // Potions + Antidote + return sellingPrice / 10 * 10; + } + + if (!item.IsTrainablePet()) + { + var maxDurability = item.GetMaximumDurabilityOfOnePiece(); + if (maxDurability > 1 && maxDurability > durability) + { + float multiplier = 1.0f - ((float)durability / maxDurability); + long loss = (long)(sellingPrice * 0.6 * multiplier); + sellingPrice -= loss; + } + } + + return RoundPrice(sellingPrice); + } + + /// + /// Calculates the repair price of the item, which the player has to pay if he wants to repair the item. + /// + /// The item. + /// If set to true, the item is repaired through an NPC which gives a discount. + /// The repair price. + public long CalculateRepairPrice(Item item, bool npcDiscount) + { + if (item.GetMaximumDurabilityOfOnePiece() == 0) + { + return 0; + } + + const long maximumBasePrice = 400_000_000; + var isPet = item.IsTrainablePet(); + var basePrice = Math.Min(this.CalculateFinalBuyingPrice(item) / (isPet ? 1 : 3), maximumBasePrice); + basePrice = RoundPrice(basePrice); + + float squareRootOfBasePrice = (float)Math.Sqrt(basePrice); + float squareRootOfSquareRoot = (float)Math.Sqrt(squareRootOfBasePrice); + float missingDurability = 1 - ((float)item.Durability() / item.GetMaximumDurabilityOfOnePiece()); + float repairPrice = (3.0f * squareRootOfBasePrice * squareRootOfSquareRoot * missingDurability) + 1.0f; + if (item.Durability <= 0) + { + if (isPet) + { + repairPrice *= DestroyedPetPenalty; + } + else + { + repairPrice *= DestroyedItemPenalty; + } + } + + if (!npcDiscount) + { + repairPrice *= 2.5f; + } + + return RoundPrice((long)repairPrice); + } + + /// + /// Calculates the final buying price of the item, which the player has to pay if he wants to buy the item from a merchant. + /// + /// The item. + /// The buying price. + public long CalculateFinalBuyingPrice(Item item) => RoundPrice(CalculateBuyingPrice(item)); + + /// + /// Calculates the final "old" buying price of the item. + /// Supposedly in earlier versions jewel reference prices were different, and those were used since for Chaos Weapon and First Wings craftings rate calculations. + /// + /// The item. + /// The "old" buying price. + public long CalculateFinalOldBuyingPrice(Item item) + { + if (SpecialItemOldValueDictionary.TryGetValue(GetId(item.Definition!.Group, item.Definition.Number), out var oldValue)) + { + return RoundPrice(oldValue); + } + else + { + return this.CalculateFinalBuyingPrice(item); + } + } + + private static int GetId(byte group, int id) + { + return (id << 8) + group; + } + + private static long RoundPrice(long price) + { + var result = price; + if (result >= 1000) + { + result = result / 100 * 100; + } + else if (result >= 100) + { + result = result / 10 * 10; + } + else + { + // no rounding for smaller values. + } + + return result; + } + + private static long CalculateBuyingPrice(Item item) + { + item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition)); + + var definition = item.Definition!; + if (definition.Value > 0 && (definition.Group == 15 || definition.Group == 12)) + { + return definition.Value; + } + + if (item.IsTrainablePet()) + { + if (item.IsDarkRaven()) + { + return item.Level * 1_000_000; + } + else + { + return item.Level * 2_000_000; + } + } + + long price = 0; + int dropLevel = definition.DropLevel + (item.Level * 3); + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent)) + { + // increased drop level of excellent item + dropLevel += 25; + } + + if (SpecialItemDictionary.TryGetValue(GetId(item.Definition.Group, item.Definition.Number), out var specialItemPriceFunction)) + { + price = specialItemPriceFunction(item); + } + else if (definition.Value > 0) + { + price += definition.Value * definition.Value * 10 / 12; + if (item.Definition.Group == 14 && (item.Definition.Number <= 8)) + { + // Potions + Antidote + if (item.Level > 0) + { + price *= (long)Math.Pow(2, item.Level); + } + + price = price / 10 * 10; + price *= item.Durability(); + return price; + } + } + else if ((item.Definition.Group == 12 + && ((item.Definition.Number > 6 && item.Definition.Number < 36) + || (item.Definition.Number > 43 && item.Definition.Number != 50))) + || item.Definition.Group == 13 + || item.Definition.Group == 15) + { + // Cape of Lord and Cape of Fighter go here + price = (dropLevel * dropLevel * dropLevel) + 100; + + if (item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option) is { } opt + && opt.ItemOption?.PowerUpDefinition?.TargetAttribute == Stats.HealthRecoveryMultiplier) + { + // Rings, pendants, and capes. Capes with physical damage option have no extra value (possibly a source bug). + price += price * opt.Level; + } + } + else + { + if (DropLevelIncreaseByLevel.TryGetValue(item.Level, out var dropLevelIncrease)) + { + dropLevel += dropLevelIncrease; + } + + // Wings + if (item.IsWing()) + { + // maybe we have to exclude small wings here + price = ((dropLevel + 40) * dropLevel * dropLevel * 11) + 40000000; + } + else + { + price = ((dropLevel + 40) * dropLevel * dropLevel / 8) + 100; + } + + var isOneHandedWeapon = item.Definition.Group < 6 && definition.Width < 2; + var isShield = item.Definition.Group == 6; + if (isOneHandedWeapon || isShield) + { + price = price * 80 / 100; + } + + if (item.HasSkill && !WorthlessSkills.Contains(definition.Skill?.Number ?? 0)) + { + price += (long)(price * 1.5); + } + + // add 25% for luck + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck)) + { + price += price * 25 / 100; + } + + var opt = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option); + var optionLevel = opt?.Level ?? 0; + + // Item Options (1 to 4, or 4 to 16) + switch (optionLevel) + { + case 0: + break; + case 1: + price += (long)(price * 0.6); + break; + default: + price += (long)(price * 0.7 * Math.Pow(2, optionLevel - 1)); + break; + } + + // For each wing option, add 25% + var wingOptionCount = item.ItemOptions.Count(o => o.ItemOption?.OptionType == ItemOptionTypes.Wing); + for (int i = 0; i < wingOptionCount; i++) + { + price += (long)(price * 0.25); + } + + // For each excellent option double the value + var excCount = item.ItemOptions.Count(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent); + for (int i = 0; i < excCount; i++) + { + price += price; + } + } + + if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.GuardianOption)) + { + price += price * 16 / 100; + } + + if (price > MaximumPrice) + { + price = MaximumPrice; + } + + return price; + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TestGameConfiguration.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TestGameConfiguration.cs index 641b7c44a1..95999e595f 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TestGameConfiguration.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TestGameConfiguration.cs @@ -35,6 +35,17 @@ internal static class TestGameConfiguration return await InitializeAsync(contextProvider, dataInitialization).ConfigureAwait(false); } + /// + /// Creates a freshly initialized 0.75 game configuration. + /// + /// The persistence context and the game configuration. + public static async Task<(IContext Context, GameConfiguration GameConfiguration)> Create075Async() + { + var contextProvider = new InMemoryPersistenceContextProvider(); + var dataInitialization = new Version075.DataInitialization(contextProvider, new NullLoggerFactory()); + return await InitializeAsync(contextProvider, dataInitialization).ConfigureAwait(false); + } + private static async Task<(IContext Context, GameConfiguration GameConfiguration)> InitializeAsync( InMemoryPersistenceContextProvider contextProvider, DataInitializationBase dataInitialization)