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