Files
AdamuSw/src/GameLogic/ItemPriceCalculator.cs
2026-07-14 19:00:35 +03:00

581 lines
24 KiB
C#

// <copyright file="ItemPriceCalculator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
/// <summary>
/// This calculator calculates the item prices.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<short> WorthlessSkills = [ForceWaveSkillId, ExplosionSkillId, RequiemSkillId, PollutionSkillId];
private static readonly Dictionary<byte, int> 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<int, long> SpecialItemOldValueDictionary = new Dictionary<int, long>
{
{ (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<int, Func<Item, long>> SpecialItemDictionary = new Dictionary<int, Func<Item, long>>
{
{
(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,
}
/// <summary>
/// Calculates the selling price of the item for its maximum durability.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The selling price.</returns>
public long CalculateSellingPrice(Item item) => this.CalculateSellingPrice(item, item.GetMaximumDurabilityOfOnePiece());
/// <summary>
/// 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.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="durability">The current durability of the <paramref name="item"/>.</param>
/// <returns>The selling price.</returns>
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);
}
/// <summary>
/// Calculates the repair price of the item, which the player has to pay if he wants to repair the item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="npcDiscount">If set to <c>true</c>, the item is repaired through an NPC which gives a discount.</param>
/// <returns>The repair price.</returns>
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);
}
/// <summary>
/// Calculates the final buying price of the item, which the player has to pay if he wants to buy the item from a merchant.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The buying price.</returns>
public long CalculateFinalBuyingPrice(Item item) => RoundPrice(CalculateBuyingPrice(item));
/// <summary>
/// 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.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The "old" buying price.</returns>
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;
}
}