From 4c0c4627ebdcc5eb1b73c0c4b1534ae881910b80 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 22:41:41 +0200 Subject: [PATCH 01/18] test(items): pin today's item requirement, power-up and price formulas for every item Adds frozen copies of the item requirement formulas (with the item checks and durability functions they use), ItemPowerUpFactory and ItemPriceCalculator at 90a571100, and a test which compares them with the current code for every item of the 0.75, 0.95d and Season 6 data: at every item level, as normal, excellent (all options and one option), ancient and excellent ancient item (made up where the data has none), at option level 0, 1 and the highest, with harmony and made-up requirement reductions, in every slot and at full, half and no durability. All differences are listed in one report. This prepares moving these formulas into configuration data: that change has to keep every value the same. --- .../ItemFormulasEqualTodayTest.cs | 487 +++++++++++++++ .../Legacy/LegacyItemExtensions.cs | 391 ++++++++++++ .../Legacy/LegacyItemPowerUpFactory.cs | 465 ++++++++++++++ .../Legacy/LegacyItemPriceCalculator.cs | 582 ++++++++++++++++++ .../TestGameConfiguration.cs | 11 + 5 files changed, 1936 insertions(+) create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs 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..da5dbc93b0 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -0,0 +1,487 @@ +// +// 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 its option and at the option's first and highest level, +/// in every slot it fits, and at full, half and no durability: 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. All differences are listed in one report. +/// +[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; + } + + /// + /// 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; + + 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 option = possibleOptions.FirstOrDefault(o => o.OptionType == ItemOptionTypes.Option); + + 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(); + + // Level 1 of an option is its own power-up, the higher levels are in its level dependent options. + // A fixed option (e.g. of the Dinorant) 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 optionLevels = new List { 0 }; + if (option is not null) + { + optionLevels.AddRange(new[] { 1, option.LevelDependentOptions.Select(o => o.Level).DefaultIfEmpty(1).Max() }.Distinct()); + } + + foreach (var quality in this.GetQualities(definition, possibleOptions)) + { + for (int level = 0; level <= definition.MaximumItemLevel; level++) + { + foreach (var optionLevel in optionLevels) + { + var item = new FormulaTestItem { 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); + } + + if (optionLevel > 0) + { + item.ItemOptions.Add(new ItemOptionLink { ItemOption = option, Level = optionLevel }); + } + + var itemCase = $"{definition.Group}/{definition.Number} {definition.Name.ValueInNeutralLanguage} +{level} {quality.Name}, option level {optionLevel}"; + this.CompareRequirements(item, quality.IsNormal && optionLevel == 0 ? allReductions : harmonyReductions, itemCase); + this.ComparePowerUps(item, itemCase); + this.ComparePrices(item, 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 $"throws {ex.GetType().Name}"; + } + } + + private static string DescribePowerUps(Item item, Func> getPowerUps) + { + using var attributes = new AttributeSystem([], [], []); + var powerUps = new List(); + try + { + powerUps.AddRange(getPowerUps(item, attributes)); + + // Each power-up added itself to its target attribute, which tells the attribute. + var elements = attributes.OfType() + .SelectMany(attribute => attribute.Elements.Select(element => + $"{attribute.Definition.Designation} {element.AggregateType} {element.Value.ToString("R", CultureInfo.InvariantCulture)}")) + .Order(StringComparer.Ordinal); + return $"{powerUps.Count}: {string.Join("; ", elements)}"; + } + 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) + { + string Describe(IEnumerable requirements) => string.Join("; ", requirements.Select(requirement => + { + var (attribute, value) = getRequirement(item, requirement); + return $"{attribute.Designation} {value}"; + })); + + 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.Differences.Add($"{itemCase}, {what}: before [{before}], now [{now}]"); + this.DifferencesByKind[what] = this.DifferencesByKind.GetValueOrDefault(what) + 1; + } + } + + private void CompareRequirements(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, string itemCase) + { + if (item.Definition!.ItemSlot is not { } slotType) + { + return; + } + + var fullDurability = item.GetMaximumDurabilityOfOnePiece(); + foreach (var slot in slotType.ItemSlots) + { + foreach (var durability in new[] { fullDurability, fullDurability / 2 }.Distinct()) + { + 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, string itemCase) + { + item.ItemSlot = (byte)(item.Definition!.ItemSlot?.ItemSlots.FirstOrDefault() ?? 0); + var fullDurability = item.GetMaximumDurabilityOfOnePiece(); + item.Durability = fullDurability; + + // The buying prices don't depend on the durability. + this.Compare(itemCase, "buying price", Capture(() => $"{this._legacyPrices.CalculateFinalBuyingPrice(item)}"), Capture(() => $"{this._prices.CalculateFinalBuyingPrice(item)}")); + this.Compare(itemCase, "old buying price", Capture(() => $"{this._legacyPrices.CalculateFinalOldBuyingPrice(item)}"), Capture(() => $"{this._prices.CalculateFinalOldBuyingPrice(item)}")); + foreach (var durability in new[] { fullDurability, fullDurability / 2, 0 }.Distinct()) + { + item.Durability = durability; + this.PriceCases++; + var priceCase = $"{itemCase}, durability {durability}"; + 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)}")); + } + } + } + + /// + /// An item which isn't persisted. + /// + private sealed class FormulaTestItem : Item + { + /// + /// Initializes a new instance of the class. + /// + public FormulaTestItem() + { + this.ItemOptions = new List(); + this.ItemSetGroups = new List(); + } + } +} 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..e10c92c45a --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs @@ -0,0 +1,391 @@ +// +// 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 the frozen formulas use, +/// which compares with the current code. Don't change it. +/// +/// +/// Lines 19-106, 131-141, 174-289, 361-426, 474-500 and 512-561 are copied unchanged. +/// 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. +/// So the copies don't depend on src/GameLogic/ItemExtensions.cs any more; they still call +/// IsWearable, IsTrainablePet, IsDarkRaven, IsWing and Durability of src/DataModel/ItemExtensions.cs. +/// +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 durability decays with time. + /// + /// The item. + /// + /// true if this instance's durability decays with time; otherwise, false. + /// + public static bool IsTimeDecaying(this Item item) + { + return item.IsJewelry() || item.IsWing(); + } + + /// + /// 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; + } + + 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..08be2a09c4 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs @@ -0,0 +1,465 @@ +// +// 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; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.Persistence; + +/// +/// A frozen copy of src/GameLogic/ItemPowerUpFactory.cs at commit 90a571100, +/// which compares with the current code. Don't change it. +/// +/// +/// Lines 17-455 are copied unchanged, except the class name in lines 30, 33 and 36. +/// The item checks, the drop level and the durability factor come from . +/// +internal class LegacyItemPowerUpFactory : IItemPowerUpFactory +{ + 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; + } + + /// + 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; + } + } + + /// + public IEnumerable GetSetPowerUps( + IEnumerable equippedItems, + AttributeSystem attributeHolder, + GameConfiguration gameConfiguration) + { + var activeItems = equippedItems + .Where(i => i.Durability > 0) + .ToList(); + var itemGroups = activeItems + .SelectMany(i => i.ItemSetGroups) + .Select(i => i.ItemSetGroup!) + .Distinct(); + + var result = Enumerable.Empty(); + var alwaysGroups = activeItems.SelectMany(i => i.Definition!.PossibleItemSetGroups).Where(i => i.AlwaysApplies).Distinct(); + + foreach (var group in alwaysGroups.Concat(itemGroups).Distinct()) + { + var itemsOfGroup = activeItems.Where(i => + ((group.AlwaysApplies && i.Definition!.PossibleItemSetGroups.Contains(group)) + || i.ItemSetGroups.Any(ios => ios.ItemSetGroup == group)) + && (group.SetLevel == 0 || i.Level >= group.SetLevel)); + var setMustBeComplete = group.MinimumItemCount == group.Items.Count; + if (group.SetLevel > 0 && setMustBeComplete && itemsOfGroup.All(i => i.Level > group.SetLevel)) + { + // When all items are of higher level and the set bonus is applied when all items are there, another item set group will take care. + // This should prevent that for example set bonus defense is applied multiple times. + continue; + } + + if (group.Options is not { } options) + { + this._logger.LogWarning("Options of set {group} is not initialized", group); + continue; + } + + var itemCount = group.CountDistinct ? itemsOfGroup.Select(item => item.Definition).Distinct().Count() : itemsOfGroup.Count(); + var setIsComplete = itemCount == group.Items.Count; + if (setIsComplete) + { + // Take all options when the set is complete + result = result.Concat( + options.PossibleOptions + .Select(o => o.PowerUpDefinition ?? throw Error.NotInitializedProperty(o, nameof(o.PowerUpDefinition)))); + continue; + } + + if (itemCount >= group.MinimumItemCount) + { + // Take the first n-1 options + result = result.Concat(options.PossibleOptions.OrderBy(o => o.Number) + .Take(itemCount - 1) + .Select(o => o.PowerUpDefinition ?? throw Error.NotInitializedProperty(o, nameof(o.PowerUpDefinition)))); + } + } + + result = result.Concat(this.GetOptionCombinationBonus(activeItems, gameConfiguration)); + + return result.SelectMany(p => PowerUpWrapper.CreateByPowerUpDefinition(p, attributeHolder)); + } + + private IEnumerable GetOptionCombinationBonus(IEnumerable activeItems, GameConfiguration gameConfiguration) + { + if (gameConfiguration?.ItemOptionCombinationBonuses is null + || gameConfiguration.ItemOptionCombinationBonuses.Count == 0) + { + yield break; + } + + var activeItemOptions = activeItems.SelectMany(i => i.ItemOptions.Select(o => o.ItemOption ?? throw Error.NotInitializedProperty(o, nameof(o.ItemOption)))).ToList(); + foreach (var combinationBonus in gameConfiguration.ItemOptionCombinationBonuses.Where(c => c.Bonus is { })) + { + var remainingOptions = activeItemOptions.ToList(); + while (this.AreRequiredOptionsFound(combinationBonus, remainingOptions)) + { + if (combinationBonus.Bonus is not null) + { + yield return combinationBonus.Bonus; + } + else + { + this._logger.LogWarning("Bonus of ItemOptionCombinationBonus '{combinationBonusName}' is not initialized, id: {id}", combinationBonus.Description, combinationBonus.GetId()); + } + + if (!combinationBonus.AppliesMultipleTimes) + { + break; + } + } + } + } + + private bool AreRequiredOptionsFound(ItemOptionCombinationBonus bonus, IList itemOptions) + { + var allMatches = new List(); + foreach (var requirement in bonus.Requirements) + { + var matches = itemOptions + .Where(o => o.OptionType is not null) + .Where(o => o.OptionType == requirement.OptionType && o.SubOptionType == requirement.SubOptionType) + .Take(requirement.MinimumCount) + .ToList(); + if (matches.Count < requirement.MinimumCount) + { + return false; + } + + allMatches.AddRange(matches); + } + + allMatches.ForEach(o => itemOptions.Remove(o)); + return true; + } + + 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..cee2b3915c --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs @@ -0,0 +1,582 @@ +// +// 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. Don't change it. +/// +/// +/// 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) From 7ca1112a0eaff2f897d28bd543f570357edc63d3 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 20:59:08 +0200 Subject: [PATCH 02/18] test(items): the frozen power-up factory doesn't implement the live interface A change of IItemPowerUpFactory, e.g. a configuration parameter, would otherwise force a change of the copy. --- .../Legacy/LegacyItemPowerUpFactory.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs index 08be2a09c4..2325ac2179 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs @@ -20,10 +20,12 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// which compares with the current code. Don't change it. /// /// -/// Lines 17-455 are copied unchanged, except the class name in lines 30, 33 and 36. +/// Lines 17-455 are copied unchanged, except the class name in lines 30, 33 and 36, and the documentation of +/// GetPowerUps (line 41) and GetSetPowerUps (line 103), 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 item checks, the drop level and the durability factor come from . /// -internal class LegacyItemPowerUpFactory : IItemPowerUpFactory +internal class LegacyItemPowerUpFactory { private static readonly HashSet DurabilityAffectedItemAttributes = [ @@ -48,7 +50,12 @@ 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) @@ -110,7 +117,13 @@ public IEnumerable GetPowerUps(Item item, AttributeSystem attrib } } - /// + /// + /// Gets the set power ups, which are created for existing s in the equipped items. + /// + /// The equipped items. + /// The attribute system of the player who equipped the items. + /// The game configuration. + /// The created set power ups. public IEnumerable GetSetPowerUps( IEnumerable equippedItems, AttributeSystem attributeHolder, From f6174ba2a509b27c93ace54d8fb1f14860a72c9d Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:01:57 +0200 Subject: [PATCH 03/18] test(items): leave the set power-ups and IsTimeDecaying out of the frozen copies The test never ran them, and they suggested that the set bonuses are pinned, which they aren't. --- .../Legacy/LegacyItemExtensions.cs | 14 +- .../Legacy/LegacyItemPowerUpFactory.cs | 129 +----------------- 2 files changed, 5 insertions(+), 138 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs index e10c92c45a..3e4263b254 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs @@ -17,7 +17,7 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// which compares with the current code. Don't change it. /// /// -/// Lines 19-106, 131-141, 174-289, 361-426, 474-500 and 512-561 are copied unchanged. +/// Lines 19-106, 131-141, 174-265, 279-289, 361-426, 474-500 and 512-561 are copied unchanged. /// 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. /// So the copies don't depend on src/GameLogic/ItemExtensions.cs any more; they still call @@ -219,18 +219,6 @@ public static bool IsBook(this Item item, [NotNullWhen(true)] out float? bookRis return bookRise is not null; } - /// - /// Determines whether this instance's durability decays with time. - /// - /// The item. - /// - /// true if this instance's durability decays with time; otherwise, false. - /// - public static bool IsTimeDecaying(this Item item) - { - return item.IsJewelry() || item.IsWing(); - } - /// /// Determines whether this instance's power-ups depend on its current durability. /// diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs index 2325ac2179..8685d1da9a 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs @@ -8,7 +8,6 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; 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; @@ -20,9 +19,10 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// which compares with the current code. Don't change it. /// /// -/// Lines 17-455 are copied unchanged, except the class name in lines 30, 33 and 36, and the documentation of -/// GetPowerUps (line 41) and GetSetPowerUps (line 103), which took it from the interface the copy doesn't implement, -/// so that a change of the live interface doesn't change the copy. +/// 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 and the durability factor come from . /// internal class LegacyItemPowerUpFactory @@ -117,127 +117,6 @@ public IEnumerable GetPowerUps(Item item, AttributeSystem attrib } } - /// - /// Gets the set power ups, which are created for existing s in the equipped items. - /// - /// The equipped items. - /// The attribute system of the player who equipped the items. - /// The game configuration. - /// The created set power ups. - public IEnumerable GetSetPowerUps( - IEnumerable equippedItems, - AttributeSystem attributeHolder, - GameConfiguration gameConfiguration) - { - var activeItems = equippedItems - .Where(i => i.Durability > 0) - .ToList(); - var itemGroups = activeItems - .SelectMany(i => i.ItemSetGroups) - .Select(i => i.ItemSetGroup!) - .Distinct(); - - var result = Enumerable.Empty(); - var alwaysGroups = activeItems.SelectMany(i => i.Definition!.PossibleItemSetGroups).Where(i => i.AlwaysApplies).Distinct(); - - foreach (var group in alwaysGroups.Concat(itemGroups).Distinct()) - { - var itemsOfGroup = activeItems.Where(i => - ((group.AlwaysApplies && i.Definition!.PossibleItemSetGroups.Contains(group)) - || i.ItemSetGroups.Any(ios => ios.ItemSetGroup == group)) - && (group.SetLevel == 0 || i.Level >= group.SetLevel)); - var setMustBeComplete = group.MinimumItemCount == group.Items.Count; - if (group.SetLevel > 0 && setMustBeComplete && itemsOfGroup.All(i => i.Level > group.SetLevel)) - { - // When all items are of higher level and the set bonus is applied when all items are there, another item set group will take care. - // This should prevent that for example set bonus defense is applied multiple times. - continue; - } - - if (group.Options is not { } options) - { - this._logger.LogWarning("Options of set {group} is not initialized", group); - continue; - } - - var itemCount = group.CountDistinct ? itemsOfGroup.Select(item => item.Definition).Distinct().Count() : itemsOfGroup.Count(); - var setIsComplete = itemCount == group.Items.Count; - if (setIsComplete) - { - // Take all options when the set is complete - result = result.Concat( - options.PossibleOptions - .Select(o => o.PowerUpDefinition ?? throw Error.NotInitializedProperty(o, nameof(o.PowerUpDefinition)))); - continue; - } - - if (itemCount >= group.MinimumItemCount) - { - // Take the first n-1 options - result = result.Concat(options.PossibleOptions.OrderBy(o => o.Number) - .Take(itemCount - 1) - .Select(o => o.PowerUpDefinition ?? throw Error.NotInitializedProperty(o, nameof(o.PowerUpDefinition)))); - } - } - - result = result.Concat(this.GetOptionCombinationBonus(activeItems, gameConfiguration)); - - return result.SelectMany(p => PowerUpWrapper.CreateByPowerUpDefinition(p, attributeHolder)); - } - - private IEnumerable GetOptionCombinationBonus(IEnumerable activeItems, GameConfiguration gameConfiguration) - { - if (gameConfiguration?.ItemOptionCombinationBonuses is null - || gameConfiguration.ItemOptionCombinationBonuses.Count == 0) - { - yield break; - } - - var activeItemOptions = activeItems.SelectMany(i => i.ItemOptions.Select(o => o.ItemOption ?? throw Error.NotInitializedProperty(o, nameof(o.ItemOption)))).ToList(); - foreach (var combinationBonus in gameConfiguration.ItemOptionCombinationBonuses.Where(c => c.Bonus is { })) - { - var remainingOptions = activeItemOptions.ToList(); - while (this.AreRequiredOptionsFound(combinationBonus, remainingOptions)) - { - if (combinationBonus.Bonus is not null) - { - yield return combinationBonus.Bonus; - } - else - { - this._logger.LogWarning("Bonus of ItemOptionCombinationBonus '{combinationBonusName}' is not initialized, id: {id}", combinationBonus.Description, combinationBonus.GetId()); - } - - if (!combinationBonus.AppliesMultipleTimes) - { - break; - } - } - } - } - - private bool AreRequiredOptionsFound(ItemOptionCombinationBonus bonus, IList itemOptions) - { - var allMatches = new List(); - foreach (var requirement in bonus.Requirements) - { - var matches = itemOptions - .Where(o => o.OptionType is not null) - .Where(o => o.OptionType == requirement.OptionType && o.SubOptionType == requirement.SubOptionType) - .Take(requirement.MinimumCount) - .ToList(); - if (matches.Count < requirement.MinimumCount) - { - return false; - } - - allMatches.AddRange(matches); - } - - allMatches.ForEach(o => itemOptions.Remove(o)); - return true; - } - private IEnumerable GetBasePowerUpWrappers(Item item, AttributeSystem attributeHolder, ItemBasePowerUpDefinition attribute, AttributeDefinition? targetAttribute = null) { attribute.ThrowNotInitializedProperty(attribute.TargetAttribute is null, nameof(attribute.TargetAttribute)); From bad1a74008e2c3e68cc13eccfcba6566c116826a Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:05:57 +0200 Subject: [PATCH 04/18] test(items): freeze IsFixedOption and name the live code the copies still call The copies no longer import the game logic's namespace, so none of its helpers can move the copy and the live code together; the remarks name the live data model code which builds the power-up elements. --- .../Legacy/LegacyItemExtensions.cs | 28 ++++++++++++++++--- .../Legacy/LegacyItemPowerUpFactory.cs | 4 +-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs index 3e4263b254..bfd8cc47b8 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs @@ -17,11 +17,16 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// which compares with the current code. Don't change it. /// /// -/// Lines 19-106, 131-141, 174-265, 279-289, 361-426, 474-500 and 512-561 are copied unchanged. +/// 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. -/// So the copies don't depend on src/GameLogic/ItemExtensions.cs any more; they still call -/// IsWearable, IsTrainablePet, IsDarkRaven, IsWing and Durability of src/DataModel/ItemExtensions.cs. +/// because extension methods of the own namespace come before imported ones; the copies don't import the namespace +/// of the game logic, so they call none of its helpers. +/// They still call live code which the item formulas don't contain: IsWearable, IsTrainablePet, +/// IsDarkRaven, IsWing and Durability of src/DataModel/ItemExtensions.cs, and the code which builds +/// the elements of the power-ups and applies the durability factor to them: PowerUpWrapper.CreateByPowerUpDefinition, +/// PowerUpDefinitionValue.GetSimpleValueElement, ItemBasePowerUpDefinition.GetBaseValueElement and +/// LevelBonus.GetAdditionalValueElement. /// internal static class LegacyItemExtensions { @@ -358,6 +363,21 @@ public static float GetCurrentDurabilityFactor(this Item item) 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) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs index 8685d1da9a..6f9cb2e885 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs @@ -10,7 +10,6 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; using MUnique.OpenMU.DataModel.Attributes; using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Entities; -using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.Persistence; @@ -23,7 +22,8 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// 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 and the durability factor come from . +/// 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 { From 27d37fcce91b274386fe9051f743c7c8bb623320 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:08:21 +0200 Subject: [PATCH 05/18] test(items): use TemporaryItem instead of a copy of it --- .../ItemFormulas/ItemFormulasEqualTodayTest.cs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index da5dbc93b0..bafbdd2790 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -231,7 +231,7 @@ public void CompareItem(ItemDefinition definition) { foreach (var optionLevel in optionLevels) { - var item = new FormulaTestItem { Definition = definition, Level = (byte)level }; + var item = new TemporaryItem { Definition = definition, Level = (byte)level }; foreach (var excellentOption in quality.ExcellentOptions) { item.ItemOptions.Add(new ItemOptionLink { ItemOption = excellentOption }); @@ -469,19 +469,4 @@ private void ComparePrices(Item item, string itemCase) } } } - - /// - /// An item which isn't persisted. - /// - private sealed class FormulaTestItem : Item - { - /// - /// Initializes a new instance of the class. - /// - public FormulaTestItem() - { - this.ItemOptions = new List(); - this.ItemSetGroups = new List(); - } - } } From 9aa15d0dc57036bc9c02e2f07c4d19eb11e37732 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:09:49 +0200 Subject: [PATCH 06/18] test(items): give the requirement comparison with reductions its own name The two overloads differed only in the element type of their list, so passing the other list would have picked the other one silently. --- .../ItemFormulas/ItemFormulasEqualTodayTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index bafbdd2790..d19d438b74 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -248,7 +248,7 @@ public void CompareItem(ItemDefinition definition) } var itemCase = $"{definition.Group}/{definition.Number} {definition.Name.ValueInNeutralLanguage} +{level} {quality.Name}, option level {optionLevel}"; - this.CompareRequirements(item, quality.IsNormal && optionLevel == 0 ? allReductions : harmonyReductions, itemCase); + this.CompareRequirementsWithReductions(item, quality.IsNormal && optionLevel == 0 ? allReductions : harmonyReductions, itemCase); this.ComparePowerUps(item, itemCase); this.ComparePrices(item, itemCase); } @@ -402,7 +402,7 @@ private void Compare(string itemCase, string what, string before, string now) } } - private void CompareRequirements(Item item, List reductions, string itemCase) + private void CompareRequirementsWithReductions(Item item, List reductions, string itemCase) { this.CompareRequirements(item, this._madeUpRequirements, itemCase); foreach (var reduction in reductions) From 7eb518affa24b63aea50f3ee08fd2d58f860107f Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:11:27 +0200 Subject: [PATCH 07/18] test(items): compare what comes before a throw, and the exception's message A throw no longer replaces the whole list of power-ups or requirements, and two failures of the same type with different messages now differ. The throwing method isn't compared, because the live code may compute the same in other methods. --- .../ItemFormulasEqualTodayTest.cs | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index d19d438b74..e1497c5776 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -311,24 +311,42 @@ private static string Capture(Func calculation) } catch (Exception ex) { - return $"throws {ex.GetType().Name}"; + 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}"; + } + private static string DescribePowerUps(Item item, Func> getPowerUps) { using var attributes = new AttributeSystem([], [], []); var powerUps = new List(); try { - powerUps.AddRange(getPowerUps(item, attributes)); + // 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.Select(element => $"{attribute.Definition.Designation} {element.AggregateType} {element.Value.ToString("R", CultureInfo.InvariantCulture)}")) .Order(StringComparer.Ordinal); - return $"{powerUps.Count}: {string.Join("; ", elements)}"; + return $"{powerUps.Count}: {string.Join("; ", elements)}{thrown}"; } finally { @@ -339,10 +357,18 @@ private static string DescribePowerUps(Item item, Func 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 => { - var (attribute, value) = getRequirement(item, requirement); - return $"{attribute.Designation} {value}"; + 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)}"; From c550304f021e09b2f13125e297226a1ffb28682f Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:12:53 +0200 Subject: [PATCH 08/18] test(items): list a maximum durability which can't be computed as a difference The cases were prepared outside the capture, so an item which throws there would have stopped the test without reporting the others. --- .../ItemFormulasEqualTodayTest.cs | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index e1497c5776..5c340cffb6 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -249,8 +249,11 @@ public void CompareItem(ItemDefinition definition) var itemCase = $"{definition.Group}/{definition.Number} {definition.Name.ValueInNeutralLanguage} +{level} {quality.Name}, option level {optionLevel}"; this.CompareRequirementsWithReductions(item, quality.IsNormal && optionLevel == 0 ? allReductions : harmonyReductions, itemCase); - this.ComparePowerUps(item, itemCase); - this.ComparePrices(item, itemCase); + if (this.GetMaximumDurability(item, itemCase) is { } maximumDurability) + { + this.ComparePowerUps(item, maximumDurability, itemCase); + this.ComparePrices(item, maximumDurability, itemCase); + } } } } @@ -423,8 +426,30 @@ private void Compare(string itemCase, string what, string before, string now) { if (before != now) { - this.Differences.Add($"{itemCase}, {what}: before [{before}], now [{now}]"); - this.DifferencesByKind[what] = this.DifferencesByKind.GetValueOrDefault(what) + 1; + 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; } } @@ -450,14 +475,13 @@ private void CompareRequirements(Item item, List madeUpReq Capture(() => DescribeRequirements(item, madeUpRequirements, (i, r) => i.GetRequirement(r)))); } - private void ComparePowerUps(Item item, string itemCase) + private void ComparePowerUps(Item item, byte fullDurability, string itemCase) { if (item.Definition!.ItemSlot is not { } slotType) { return; } - var fullDurability = item.GetMaximumDurabilityOfOnePiece(); foreach (var slot in slotType.ItemSlots) { foreach (var durability in new[] { fullDurability, fullDurability / 2 }.Distinct()) @@ -474,10 +498,9 @@ private void ComparePowerUps(Item item, string itemCase) } } - private void ComparePrices(Item item, string itemCase) + private void ComparePrices(Item item, byte fullDurability, string itemCase) { item.ItemSlot = (byte)(item.Definition!.ItemSlot?.ItemSlots.FirstOrDefault() ?? 0); - var fullDurability = item.GetMaximumDurabilityOfOnePiece(); item.Durability = fullDurability; // The buying prices don't depend on the durability. From e7963e3116ec1a3438d72502494bfe7094001de5 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:14:35 +0200 Subject: [PATCH 09/18] test(items): compare the values of power-ups which follow the character level The empty attribute system read the total level as 0, so the excellent damage +level/20 options were compared by their target only. --- .../ItemFormulas/ItemFormulasEqualTodayTest.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index 5c340cffb6..1671dd8a73 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -113,6 +113,11 @@ private sealed class FormulaComparison 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 }, @@ -330,6 +335,7 @@ private static string DescribeException(Exception exception) 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 { @@ -346,7 +352,7 @@ private static string DescribePowerUps(Item item, Func() - .SelectMany(attribute => attribute.Elements.Select(element => + .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}"; From bee15076c4670d7b617765a57a36ba48f9666946 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:16:13 +0200 Subject: [PATCH 10/18] test(items): compare a durability below each threshold of the durability factor Half of an even maximum durability isn't below the 50 % threshold, so the factor 0.8 showed only for odd maxima, and 0.7 and 0.5 never. --- .../ItemFormulas/ItemFormulasEqualTodayTest.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index 1671dd8a73..d1a082a4b4 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -21,7 +21,8 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas; /// /// /// Each item is checked at every item level, without its option and at the option's first and highest level, -/// in every slot it fits, and at full, half and no durability: as a normal item, as an excellent item with all its +/// in every slot it fits, and at full durability, below each threshold of the durability factor (at 40, 25 and 10 % of it) +/// and, for the prices, at none: 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 @@ -332,6 +333,15 @@ 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([], [], []); @@ -490,7 +500,7 @@ private void ComparePowerUps(Item item, byte fullDurability, string itemCase) foreach (var slot in slotType.ItemSlots) { - foreach (var durability in new[] { fullDurability, fullDurability / 2 }.Distinct()) + foreach (var durability in GetDurabilities(fullDurability)) { item.ItemSlot = (byte)slot; item.Durability = durability; @@ -512,7 +522,7 @@ private void ComparePrices(Item item, byte fullDurability, string itemCase) // The buying prices don't depend on the durability. this.Compare(itemCase, "buying price", Capture(() => $"{this._legacyPrices.CalculateFinalBuyingPrice(item)}"), Capture(() => $"{this._prices.CalculateFinalBuyingPrice(item)}")); this.Compare(itemCase, "old buying price", Capture(() => $"{this._legacyPrices.CalculateFinalOldBuyingPrice(item)}"), Capture(() => $"{this._prices.CalculateFinalOldBuyingPrice(item)}")); - foreach (var durability in new[] { fullDurability, fullDurability / 2, 0 }.Distinct()) + foreach (var durability in GetDurabilities(fullDurability).Append(0).Distinct()) { item.Durability = durability; this.PriceCases++; From b45ee6feb0775bc4991399b6d4e558fbabecb2ca Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:18:07 +0200 Subject: [PATCH 11/18] test(items): compare the buying prices at each durability, and full stacks The buying prices of potions, arrows and other stackable items multiply the number of pieces, which is their durability; a stack of more than one piece wasn't priced. --- .../ItemFormulas/ItemFormulasEqualTodayTest.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index d1a082a4b4..9eb7f58786 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -517,16 +517,18 @@ private void ComparePowerUps(Item item, byte fullDurability, string itemCase) private void ComparePrices(Item item, byte fullDurability, string itemCase) { item.ItemSlot = (byte)(item.Definition!.ItemSlot?.ItemSlots.FirstOrDefault() ?? 0); - item.Durability = fullDurability; - // The buying prices don't depend on the durability. - this.Compare(itemCase, "buying price", Capture(() => $"{this._legacyPrices.CalculateFinalBuyingPrice(item)}"), Capture(() => $"{this._prices.CalculateFinalBuyingPrice(item)}")); - this.Compare(itemCase, "old buying price", Capture(() => $"{this._legacyPrices.CalculateFinalOldBuyingPrice(item)}"), Capture(() => $"{this._prices.CalculateFinalOldBuyingPrice(item)}")); - foreach (var durability in GetDurabilities(fullDurability).Append(0).Distinct()) + // 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() + : GetDurabilities(fullDurability).Append(0).Distinct(); + 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)}")); From 0871b75acd0029586ca86dd44be2cc6f130e881a Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:19:57 +0200 Subject: [PATCH 12/18] test(items): compare the prices with skill, luck, wing and guardian options The prices add a surcharge for a skill, luck, each wing option and the option of the Jewel of Guardian, which no compared item had. --- .../ItemFormulasEqualTodayTest.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index 9eb7f58786..48c473c5b5 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -222,6 +222,13 @@ public void CompareItem(ItemDefinition definition) 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(); + // Level 1 of an option is its own power-up, the higher levels are in its level dependent options. // A fixed option (e.g. of the Dinorant) 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. @@ -258,7 +265,7 @@ public void CompareItem(ItemDefinition definition) if (this.GetMaximumDurability(item, itemCase) is { } maximumDurability) { this.ComparePowerUps(item, maximumDurability, itemCase); - this.ComparePrices(item, maximumDurability, itemCase); + this.ComparePrices(item, maximumDurability, priceExtras, itemCase); } } } @@ -514,7 +521,24 @@ private void ComparePowerUps(Item item, byte fullDurability, string itemCase) } } - private void ComparePrices(Item item, byte fullDurability, string itemCase) + private void ComparePrices(Item item, byte fullDurability, List priceExtras, string itemCase) + { + this.ComparePricesAtEachDurability(item, fullDurability, 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.ComparePricesAtEachDurability(item, fullDurability, $"{itemCase}, with {(item.HasSkill ? "skill, " : string.Empty)}{string.Join(", ", priceExtras.Select(o => o.OptionType?.Name))}"); + item.HasSkill = false; + extraLinks.ForEach(link => item.ItemOptions.Remove(link)); + } + + private void ComparePricesAtEachDurability(Item item, byte fullDurability, string itemCase) { item.ItemSlot = (byte)(item.Definition!.ItemSlot?.ItemSlots.FirstOrDefault() ?? 0); From 14b6c689aabd17cbc2f7fc7bc7903d5b50202108 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:21:33 +0200 Subject: [PATCH 13/18] test(items): compare each normal option of an item, and all fixed options together Only the first option of type Option was tested, but wings, capes and the Dinorant have two or three. --- .../ItemFormulasEqualTodayTest.cs | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index 48c473c5b5..f304e72db5 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -20,7 +20,8 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas; /// 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 its option and at the option's first and highest level, +/// 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, and at full durability, below each threshold of the durability factor (at 40, 25 and 10 % of it) /// and, for the prices, at none: 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 @@ -97,6 +98,14 @@ private sealed record ItemQuality(string Name, IReadOnlyList 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. /// @@ -208,7 +217,6 @@ public FormulaComparison(IContext context) public void CompareItem(ItemDefinition definition) { var possibleOptions = definition.PossibleItemOptions.SelectMany(o => o.PossibleOptions).ToList(); - var option = possibleOptions.FirstOrDefault(o => o.OptionType == ItemOptionTypes.Option); var harmonyReductions = new List(); foreach (var harmonyOption in possibleOptions.Where(o => o.OptionType == ItemOptionTypes.HarmonyOption && ReducesRequirement(o))) @@ -229,20 +237,31 @@ public void CompareItem(ItemDefinition definition) .Concat(possibleOptions.Where(o => o.OptionType == ItemOptionTypes.GuardianOption).Take(1)) .ToList(); - // Level 1 of an option is its own power-up, the higher levels are in its level dependent options. - // A fixed option (e.g. of the Dinorant) 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 optionLevels = new List { 0 }; - if (option is not null) + // 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)) { - optionLevels.AddRange(new[] { 1, option.LevelDependentOptions.Select(o => o.Level).DefaultIfEmpty(1).Max() }.Distinct()); + 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 optionLevel in optionLevels) + foreach (var optionVariant in optionVariants) { var item = new TemporaryItem { Definition = definition, Level = (byte)level }; foreach (var excellentOption in quality.ExcellentOptions) @@ -255,13 +274,13 @@ public void CompareItem(ItemDefinition definition) AddAncientSet(item, ancientSetItem); } - if (optionLevel > 0) + foreach (var option in optionVariant.Options) { - item.ItemOptions.Add(new ItemOptionLink { ItemOption = option, Level = optionLevel }); + item.ItemOptions.Add(new ItemOptionLink { ItemOption = option, Level = optionVariant.Level }); } - var itemCase = $"{definition.Group}/{definition.Number} {definition.Name.ValueInNeutralLanguage} +{level} {quality.Name}, option level {optionLevel}"; - this.CompareRequirementsWithReductions(item, quality.IsNormal && optionLevel == 0 ? allReductions : harmonyReductions, itemCase); + 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) { this.ComparePowerUps(item, maximumDurability, itemCase); From 2cf5bc3ab43a027eb3c6b461c7baa326a4d524ef Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:41:13 +0200 Subject: [PATCH 14/18] test(items): say what the formula test guards and what an intentional change needs The copies said only "Don't change it"; now the test's remarks say how to update them after an intentional formula change and when they can be removed. --- .../ItemFormulas/ItemFormulasEqualTodayTest.cs | 6 ++++++ .../ItemFormulas/Legacy/LegacyItemExtensions.cs | 3 ++- .../ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs | 3 ++- .../ItemFormulas/Legacy/LegacyItemPriceCalculator.cs | 3 ++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index f304e72db5..7ff0469cc3 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -30,6 +30,12 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas; /// 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. 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 diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs index bfd8cc47b8..c9c91e8094 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs @@ -14,7 +14,8 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// /// A frozen copy of the item requirement formulas in src/GameLogic/ItemExtensions.cs at commit 90a571100, /// with the item checks and durability functions the frozen formulas use, -/// which compares with the current code. Don't change it. +/// 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 diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs index 6f9cb2e885..9bef4fc5ad 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs @@ -15,7 +15,8 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// /// A frozen copy of src/GameLogic/ItemPowerUpFactory.cs at commit 90a571100, -/// which compares with the current code. Don't change it. +/// 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 diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs index cee2b3915c..2f587f509d 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPriceCalculator.cs @@ -11,7 +11,8 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// /// A frozen copy of src/GameLogic/ItemPriceCalculator.cs at commit 90a571100, -/// which compares with the current code. Don't change it. +/// 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 . From b0c71a7b10d7a3bb77bb95abd18e34fdb608b74a Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:48:19 +0200 Subject: [PATCH 15/18] test(items): keep the run time of the formula test The durability factor multiplies the same parts at every item level, so the durabilities below its thresholds are compared at the lowest and the highest item level; the prices, which have no durability factor, at full, 40 % and no durability, and with the item's skill and extra options at full durability. Season 6 takes about 33 s instead of 61 s. --- .../ItemFormulasEqualTodayTest.cs | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index 7ff0469cc3..8bd2cebf78 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -22,14 +22,16 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas; /// /// 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, and at full durability, below each threshold of the durability factor (at 40, 25 and 10 % of it) -/// and, for the prices, at none: as a normal item, as an excellent item with all its +/// 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. All differences are listed in one report. +/// 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 @@ -289,7 +291,10 @@ public void CompareItem(ItemDefinition definition) this.CompareRequirementsWithReductions(item, quality.IsNormal && optionVariant.Options.Count == 0 ? allReductions : harmonyReductions, itemCase); if (this.GetMaximumDurability(item, itemCase) is { } maximumDurability) { - this.ComparePowerUps(item, maximumDurability, itemCase); + // 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); } } @@ -523,7 +528,7 @@ private void CompareRequirements(Item item, List madeUpReq Capture(() => DescribeRequirements(item, madeUpRequirements, (i, r) => i.GetRequirement(r)))); } - private void ComparePowerUps(Item item, byte fullDurability, string itemCase) + private void ComparePowerUps(Item item, byte fullDurability, bool allDurabilities, string itemCase) { if (item.Definition!.ItemSlot is not { } slotType) { @@ -532,7 +537,7 @@ private void ComparePowerUps(Item item, byte fullDurability, string itemCase) foreach (var slot in slotType.ItemSlots) { - foreach (var durability in GetDurabilities(fullDurability)) + foreach (var durability in allDurabilities ? GetDurabilities(fullDurability) : [fullDurability]) { item.ItemSlot = (byte)slot; item.Durability = durability; @@ -548,7 +553,14 @@ private void ComparePowerUps(Item item, byte fullDurability, string itemCase) private void ComparePrices(Item item, byte fullDurability, List priceExtras, string itemCase) { - this.ComparePricesAtEachDurability(item, fullDurability, 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; @@ -558,19 +570,13 @@ private void ComparePrices(Item item, byte fullDurability, List new ItemOptionLink { ItemOption = option, Level = 1 }).ToList(); extraLinks.ForEach(item.ItemOptions.Add); item.HasSkill = item.Definition!.Skill is not null; - this.ComparePricesAtEachDurability(item, fullDurability, $"{itemCase}, with {(item.HasSkill ? "skill, " : string.Empty)}{string.Join(", ", priceExtras.Select(o => o.OptionType?.Name))}"); + this.ComparePricesAtDurabilities(item, [fullDurability], $"{itemCase}, with {(item.HasSkill ? "skill, " : string.Empty)}{string.Join(", ", priceExtras.Select(o => o.OptionType?.Name))}"); item.HasSkill = false; extraLinks.ForEach(link => item.ItemOptions.Remove(link)); } - private void ComparePricesAtEachDurability(Item item, byte fullDurability, string itemCase) + private void ComparePricesAtDurabilities(Item item, List durabilities, string itemCase) { - item.ItemSlot = (byte)(item.Definition!.ItemSlot?.ItemSlots.FirstOrDefault() ?? 0); - - // 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() - : GetDurabilities(fullDurability).Append(0).Distinct(); foreach (var durability in durabilities) { item.Durability = durability; From 738f7b9a811fb90d44423b23f2b0863573461146 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 21:54:45 +0200 Subject: [PATCH 16/18] test(items): name the extra options of a price case in the neutral language The item names already were; the option type names followed the culture of the machine. --- .../ItemFormulas/ItemFormulasEqualTodayTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs index 8bd2cebf78..4bd588c91b 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/ItemFormulasEqualTodayTest.cs @@ -570,7 +570,7 @@ private void ComparePrices(Item item, byte fullDurability, List 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))}"); + 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)); } From 8e464f191cacd94e1f0bfdbb9559c6f39cd610f9 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sat, 24 Oct 2026 23:42:29 +0200 Subject: [PATCH 17/18] test(items): the copies' remarks name all the live code they use They import MUnique.OpenMU.GameLogic.Attributes, so PowerUpWrapper and Stats are live code too; only the extension methods of the game logic can't change the copies and the live code together. --- .../ItemFormulas/Legacy/LegacyItemExtensions.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs index c9c91e8094..1712966ed4 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemExtensions.cs @@ -13,7 +13,7 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// /// A frozen copy of the item requirement formulas in src/GameLogic/ItemExtensions.cs at commit 90a571100, -/// with the item checks and durability functions the frozen formulas use, +/// 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. /// @@ -21,13 +21,14 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; /// 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 the namespace -/// of the game logic, so they call none of its helpers. -/// They still call live code which the item formulas don't contain: IsWearable, IsTrainablePet, -/// IsDarkRaven, IsWing and Durability of src/DataModel/ItemExtensions.cs, and the code which builds -/// the elements of the power-ups and applies the durability factor to them: PowerUpWrapper.CreateByPowerUpDefinition, -/// PowerUpDefinitionValue.GetSimpleValueElement, ItemBasePowerUpDefinition.GetBaseValueElement and -/// LevelBonus.GetAdditionalValueElement. +/// 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 { From aeb0b0f7f51026a377f6909f7236898301bc1a7c Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Sun, 25 Oct 2026 00:08:49 +0200 Subject: [PATCH 18/18] test(items): remove an unnecessary using from the frozen power-up factory The copy's namespace lies within MUnique.OpenMU.Persistence, so GetId() resolves without it (Codacy). --- .../ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs index 9bef4fc5ad..bbf770f59e 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/LegacyItemPowerUpFactory.cs @@ -11,7 +11,6 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests.ItemFormulas.Legacy; using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.Attributes; -using MUnique.OpenMU.Persistence; /// /// A frozen copy of src/GameLogic/ItemPowerUpFactory.cs at commit 90a571100,