// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic; using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.GameLogic.Attributes; /// /// This calculator calculates the item prices. /// /// /// At the moment it looks all pretty hard coded (like at the original server), so maybe in a future version we could /// write a more generic calculator, which can be influenced by configuration instead of hard-coded item ids. /// A nice approach would be to have a "rule" for each item definition, about how to calculate the price. /// public class ItemPriceCalculator { 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; } }