diff --git a/documentation/DocumentationTest.MateStatDocumentation.approved.md b/documentation/DocumentationTest.MateStatDocumentation.approved.md
new file mode 100644
index 0000000..84867ca
--- /dev/null
+++ b/documentation/DocumentationTest.MateStatDocumentation.approved.md
@@ -0,0 +1,53 @@
+# Pet Stat Table
+
+Race 0, before the entry's flat modifiers, for a few growth factors (melee/ranged/magic).
+
+## Growth 0/0/0
+- Level 1 - Defence: 18/17/13 - Dodge: 31 - Melee: 32~40 hit 27 - Ranged: 27~35 hit 34 - Magic: 22~30 hit 70
+- Level 2 - Defence: 20/20/15 - Dodge: 36 - Melee: 36~46 hit 32 - Ranged: 31~41 hit 40 - Magic: 26~36 hit 70
+- Level 10 - Defence: 41/43/35 - Dodge: 76 - Melee: 69~93 hit 72 - Ranged: 64~88 hit 88 - Magic: 59~83 hit 70
+- Level 20 - Defence: 67/72/61 - Dodge: 126 - Melee: 111~151 hit 122 - Ranged: 106~146 hit 148 - Magic: 101~141 hit 70
+- Level 40 - Defence: 118/129/111 - Dodge: 226 - Melee: 195~267 hit 222 - Ranged: 190~262 hit 268 - Magic: 185~257 hit 70
+- Level 60 - Defence: 170/186/162 - Dodge: 326 - Melee: 279~383 hit 322 - Ranged: 274~378 hit 388 - Magic: 269~373 hit 70
+- Level 80 - Defence: 221/243/213 - Dodge: 426 - Melee: 363~499 hit 422 - Ranged: 358~494 hit 508 - Magic: 353~489 hit 70
+- Level 99 - Defence: 271/297/262 - Dodge: 521 - Melee: 443~609 hit 517 - Ranged: 438~604 hit 622 - Magic: 433~599 hit 70
+
+## Growth 10/0/0
+- Level 1 - Defence: 18/17/13 - Dodge: 31 - Melee: 32~40 hit 27 - Ranged: 27~35 hit 34 - Magic: 22~30 hit 70
+- Level 2 - Defence: 21/20/15 - Dodge: 36 - Melee: 37~47 hit 32 - Ranged: 31~41 hit 40 - Magic: 26~36 hit 70
+- Level 10 - Defence: 46/43/35 - Dodge: 76 - Melee: 78~102 hit 72 - Ranged: 64~88 hit 88 - Magic: 59~83 hit 70
+- Level 20 - Defence: 77/72/61 - Dodge: 126 - Melee: 130~170 hit 122 - Ranged: 106~146 hit 148 - Magic: 101~141 hit 70
+- Level 40 - Defence: 138/129/111 - Dodge: 226 - Melee: 234~306 hit 222 - Ranged: 190~262 hit 268 - Magic: 185~257 hit 70
+- Level 60 - Defence: 200/186/162 - Dodge: 326 - Melee: 338~442 hit 322 - Ranged: 274~378 hit 388 - Magic: 269~373 hit 70
+- Level 80 - Defence: 261/243/213 - Dodge: 426 - Melee: 442~578 hit 422 - Ranged: 358~494 hit 508 - Magic: 353~489 hit 70
+- Level 99 - Defence: 320/297/262 - Dodge: 521 - Melee: 541~707 hit 517 - Ranged: 438~604 hit 622 - Magic: 433~599 hit 70
+
+## Growth 0/10/0
+- Level 1 - Defence: 18/17/13 - Dodge: 31 - Melee: 32~40 hit 27 - Ranged: 27~35 hit 34 - Magic: 22~30 hit 70
+- Level 2 - Defence: 20/20/15 - Dodge: 37 - Melee: 36~46 hit 33 - Ranged: 32~42 hit 42 - Magic: 26~36 hit 70
+- Level 10 - Defence: 41/47/35 - Dodge: 85 - Melee: 69~93 hit 81 - Ranged: 73~97 hit 106 - Magic: 59~83 hit 70
+- Level 20 - Defence: 67/81/61 - Dodge: 145 - Melee: 111~151 hit 141 - Ranged: 125~165 hit 186 - Magic: 101~141 hit 70
+- Level 40 - Defence: 118/148/111 - Dodge: 265 - Melee: 195~267 hit 261 - Ranged: 229~301 hit 346 - Magic: 185~257 hit 70
+- Level 60 - Defence: 170/215/162 - Dodge: 385 - Melee: 279~383 hit 381 - Ranged: 333~437 hit 506 - Magic: 269~373 hit 70
+- Level 80 - Defence: 221/282/213 - Dodge: 505 - Melee: 363~499 hit 501 - Ranged: 437~573 hit 666 - Magic: 353~489 hit 70
+- Level 99 - Defence: 271/346/262 - Dodge: 619 - Melee: 443~609 hit 615 - Ranged: 536~702 hit 818 - Magic: 433~599 hit 70
+
+## Growth 0/0/10
+- Level 1 - Defence: 18/17/13 - Dodge: 31 - Melee: 32~40 hit 27 - Ranged: 27~35 hit 34 - Magic: 22~30 hit 70
+- Level 2 - Defence: 20/20/16 - Dodge: 36 - Melee: 36~46 hit 32 - Ranged: 31~41 hit 40 - Magic: 27~37 hit 70
+- Level 10 - Defence: 41/43/40 - Dodge: 76 - Melee: 69~93 hit 72 - Ranged: 64~88 hit 88 - Magic: 68~92 hit 70
+- Level 20 - Defence: 67/72/71 - Dodge: 126 - Melee: 111~151 hit 122 - Ranged: 106~146 hit 148 - Magic: 120~160 hit 70
+- Level 40 - Defence: 118/129/131 - Dodge: 226 - Melee: 195~267 hit 222 - Ranged: 190~262 hit 268 - Magic: 224~296 hit 70
+- Level 60 - Defence: 170/186/192 - Dodge: 326 - Melee: 279~383 hit 322 - Ranged: 274~378 hit 388 - Magic: 328~432 hit 70
+- Level 80 - Defence: 221/243/253 - Dodge: 426 - Melee: 363~499 hit 422 - Ranged: 358~494 hit 508 - Magic: 432~568 hit 70
+- Level 99 - Defence: 271/297/311 - Dodge: 521 - Melee: 443~609 hit 517 - Ranged: 438~604 hit 622 - Magic: 531~697 hit 70
+
+## Growth 5/5/5
+- Level 1 - Defence: 18/17/13 - Dodge: 31 - Melee: 32~40 hit 27 - Ranged: 27~35 hit 34 - Magic: 22~30 hit 70
+- Level 2 - Defence: 20/20/15 - Dodge: 36 - Melee: 36~46 hit 32 - Ranged: 31~41 hit 40 - Magic: 26~36 hit 70
+- Level 10 - Defence: 43/45/37 - Dodge: 80 - Melee: 73~97 hit 76 - Ranged: 68~92 hit 96 - Magic: 63~87 hit 70
+- Level 20 - Defence: 72/76/66 - Dodge: 135 - Melee: 120~160 hit 131 - Ranged: 115~155 hit 166 - Magic: 110~150 hit 70
+- Level 40 - Defence: 128/138/121 - Dodge: 245 - Melee: 214~286 hit 241 - Ranged: 209~281 hit 306 - Magic: 204~276 hit 70
+- Level 60 - Defence: 185/200/177 - Dodge: 355 - Melee: 308~412 hit 351 - Ranged: 303~407 hit 446 - Magic: 298~402 hit 70
+- Level 80 - Defence: 241/262/233 - Dodge: 465 - Melee: 402~538 hit 461 - Ranged: 397~533 hit 586 - Magic: 392~528 hit 70
+- Level 99 - Defence: 295/322/286 - Dodge: 570 - Melee: 492~658 hit 566 - Ranged: 487~653 hit 720 - Magic: 482~648 hit 70
diff --git a/src/NosCore.Algorithm/MateStatService/IMateStatService.cs b/src/NosCore.Algorithm/MateStatService/IMateStatService.cs
new file mode 100644
index 0000000..2eca674
--- /dev/null
+++ b/src/NosCore.Algorithm/MateStatService/IMateStatService.cs
@@ -0,0 +1,83 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+// -----------------------------------
+
+using NosCore.Algorithm.MonsterStatService;
+
+namespace NosCore.Algorithm.MateStatService
+{
+ ///
+ /// Service for calculating a pet's attack, defence and dodge at its own level, before the flat modifiers of its
+ /// monster entry
+ ///
+ public interface IMateStatService
+ {
+ ///
+ /// Gets the close combat defence of a pet
+ ///
+ /// The race of the pet's monster entry
+ /// The pet level
+ /// The growth factors of the pet's monster entry
+ /// The defence value
+ int GetCloseDefence(byte race, byte level, MateGrowth growth);
+
+ ///
+ /// Gets the ranged defence of a pet
+ ///
+ /// The race of the pet's monster entry
+ /// The pet level
+ /// The growth factors of the pet's monster entry
+ /// The defence value
+ int GetDistanceDefence(byte race, byte level, MateGrowth growth);
+
+ ///
+ /// Gets the magic defence of a pet
+ ///
+ /// The race of the pet's monster entry
+ /// The pet level
+ /// The growth factors of the pet's monster entry
+ /// The defence value
+ int GetMagicDefence(byte race, byte level, MateGrowth growth);
+
+ ///
+ /// Gets the dodge of a pet, against close and ranged attacks alike
+ ///
+ /// The race of the pet's monster entry
+ /// The pet level
+ /// The growth factors of the pet's monster entry
+ /// The dodge value
+ int GetDodge(byte race, byte level, MateGrowth growth);
+
+ ///
+ /// Gets the minimum damage of a pet
+ ///
+ /// The race of the pet's monster entry
+ /// The pet level
+ /// Whether the pet attacks in melee, at range or with magic
+ /// The growth factors of the pet's monster entry
+ /// The damage value
+ int GetMinimumDamage(byte race, byte level, MonsterAttackClass attackClass, MateGrowth growth);
+
+ ///
+ /// Gets the maximum damage of a pet
+ ///
+ /// The race of the pet's monster entry
+ /// The pet level
+ /// Whether the pet attacks in melee, at range or with magic
+ /// The growth factors of the pet's monster entry
+ /// The damage value
+ int GetMaximumDamage(byte race, byte level, MonsterAttackClass attackClass, MateGrowth growth);
+
+ ///
+ /// Gets the hit rate of a pet
+ ///
+ /// The race of the pet's monster entry
+ /// The pet level
+ /// Whether the pet attacks in melee, at range or with magic
+ /// The growth factors of the pet's monster entry
+ /// The hit rate value
+ int GetHitRate(byte race, byte level, MonsterAttackClass attackClass, MateGrowth growth);
+ }
+}
diff --git a/src/NosCore.Algorithm/MateStatService/MateGrowth.cs b/src/NosCore.Algorithm/MateStatService/MateGrowth.cs
new file mode 100644
index 0000000..f0d45a4
--- /dev/null
+++ b/src/NosCore.Algorithm/MateStatService/MateGrowth.cs
@@ -0,0 +1,16 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+// -----------------------------------
+
+namespace NosCore.Algorithm.MateStatService
+{
+ ///
+ /// The growth factors of a pet's monster entry, in tenths of a stat point per level
+ ///
+ /// Grows close defence and melee damage
+ /// Grows ranged defence, dodge, hit rate and ranged damage
+ /// Grows magic defence and magic damage
+ public readonly record struct MateGrowth(short Melee, short Ranged, short Magic);
+}
diff --git a/src/NosCore.Algorithm/MateStatService/MateStatService.cs b/src/NosCore.Algorithm/MateStatService/MateStatService.cs
new file mode 100644
index 0000000..61dfa26
--- /dev/null
+++ b/src/NosCore.Algorithm/MateStatService/MateStatService.cs
@@ -0,0 +1,56 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+// -----------------------------------
+
+using NosCore.Algorithm.MonsterStatService;
+
+namespace NosCore.Algorithm.MateStatService
+{
+ ///
+ /// Calculates a pet's attack, defence and dodge at its own level
+ ///
+ ///
+ /// A pet follows the monster curves of its race with its weapon and armour at the pet's level, and grows each
+ /// curve's level share by its growth factor: tenths per level above the first for dodge, damage and hit rate,
+ /// twentieths for the defences.
+ ///
+ public class MateStatService : IMateStatService
+ {
+ ///
+ public int GetCloseDefence(byte race, byte level, MateGrowth growth) =>
+ MonsterCurves.CloseDefence(race, level, level, growth.Melee);
+
+ ///
+ public int GetDistanceDefence(byte race, byte level, MateGrowth growth) =>
+ MonsterCurves.DistanceDefence(race, level, level, growth.Ranged);
+
+ ///
+ public int GetMagicDefence(byte race, byte level, MateGrowth growth) =>
+ MonsterCurves.MagicDefence(race, level, level, growth.Magic);
+
+ ///
+ public int GetDodge(byte race, byte level, MateGrowth growth) =>
+ MonsterCurves.Dodge(race, level, level, growth.Ranged);
+
+ ///
+ public int GetMinimumDamage(byte race, byte level, MonsterAttackClass attackClass, MateGrowth growth) =>
+ MonsterCurves.MinimumDamage(race, level, level, attackClass, AttackGrowth(attackClass, growth));
+
+ ///
+ public int GetMaximumDamage(byte race, byte level, MonsterAttackClass attackClass, MateGrowth growth) =>
+ MonsterCurves.MaximumDamage(race, level, level, attackClass, AttackGrowth(attackClass, growth));
+
+ ///
+ public int GetHitRate(byte race, byte level, MonsterAttackClass attackClass, MateGrowth growth) =>
+ MonsterCurves.HitRate(race, level, level, attackClass, growth.Ranged);
+
+ private static int AttackGrowth(MonsterAttackClass attackClass, MateGrowth growth) => attackClass switch
+ {
+ MonsterAttackClass.Ranged => growth.Ranged,
+ MonsterAttackClass.Magic => growth.Magic,
+ _ => growth.Melee,
+ };
+ }
+}
diff --git a/src/NosCore.Algorithm/MonsterStatService/MonsterCurves.cs b/src/NosCore.Algorithm/MonsterStatService/MonsterCurves.cs
new file mode 100644
index 0000000..eaf939a
--- /dev/null
+++ b/src/NosCore.Algorithm/MonsterStatService/MonsterCurves.cs
@@ -0,0 +1,138 @@
+// __ _ __ __ ___ __ ___ ___
+// | \| |/__\ /' _/ / _//__\| _ \ __|
+// | | ' | \/ |`._`.| \_| \/ | v / _|
+// |_|\__|\__/ |___/ \__/\__/|_|_\___|
+// -----------------------------------
+
+namespace NosCore.Algorithm.MonsterStatService
+{
+ ///
+ /// The race curves shared by monsters and pets. A pet adds its growth factor, in tenths per level above the
+ /// first, to the level share of each curve: half of it for the defences, all of it for dodge, damage and hit rate.
+ ///
+ internal static class MonsterCurves
+ {
+ private const int ArmorDefencePerLevel = 2;
+ private const int ArmorDodgePerLevel = 4;
+ private const int WeaponMinimumDamagePerLevel = 3;
+ private const int WeaponMaximumDamagePerLevel = 5;
+ private const int WeaponHitRatePerLevel = 4;
+ private const int MagicHitRate = 70;
+ private const int DefenceGrowthDivisor = 20;
+ private const int AttackGrowthDivisor = 10;
+
+ private static readonly RaceCurves Default = new(
+ new(1, 2, 1, 15), new(1, 2, 0, 14), new(1, 2, 1, 10), new(1, 1, 0, 26),
+ new(1, 1, 0, 28), new(1, 1, 0, 34), new(1, 1, 0, 22),
+ new(1, 1, 0, 23), new(1, 1, 0, 29), 28,
+ new(1, 1, 0, 18), new(1, 1, 0, 24));
+
+ // Race 4 has two level 30 entries whose published stats fit none of these curves, so the low end
+ // of that race is unconfirmed.
+ private static readonly RaceCurves?[] Races =
+ [
+ Default,
+ new(new(1, 1, 0, 19), new(1, 1, 0, 17), new(1, 1, 0, 18), new(2, 1, 0, 33),
+ new(2, 1, 0, 35), new(2, 1, 0, 41), new(2, 1, 0, 29),
+ new(2, 1, 0, 30), new(2, 1, 0, 36), 42,
+ new(2, 1, 0, 33), new(2, 1, 0, 39)),
+ new(new(3, 4, 1, 14), new(1, 2, 1, 15), new(2, 5, 3, 14), new(1, 1, 0, 29),
+ new(3, 2, 1, 25), new(3, 2, 1, 31), new(1, 1, 0, 25),
+ new(1, 1, 0, 26), new(1, 1, 0, 32), 34,
+ new(4, 5, 1, 26), new(4, 5, 1, 32)),
+ new(new(1, 2, 1, 14), new(1, 2, 1, 15), new(1, 2, 1, 14), new(1, 1, 0, 29),
+ new(1, 1, 0, 26), new(1, 1, 0, 32), new(1, 1, 0, 25),
+ new(1, 1, 0, 26), new(1, 1, 0, 32), 34,
+ new(1, 1, 0, 26), new(1, 1, 0, 32)),
+ new(new(3, 5, 4, 16), new(3, 5, 4, 17), new(1, 1, 0, 16), new(6, 5, 4, 33),
+ new(6, 5, 4, 30), new(6, 5, 4, 36), new(6, 5, 4, 29),
+ new(6, 5, 4, 30), new(6, 5, 4, 36), 42,
+ new(2, 1, 0, 30), new(2, 1, 0, 36)),
+ new(new(2, 5, 0, 13), new(2, 5, 0, 14), new(2, 5, 0, 13), new(4, 5, 1, 26),
+ new(4, 5, 1, 23), new(4, 5, 1, 29), new(4, 5, 1, 22),
+ new(4, 5, 1, 23), new(4, 5, 1, 29), 28,
+ new(4, 5, 1, 23), new(4, 5, 1, 29)),
+ new(new(1, 2, 0, 11), new(1, 2, 1, 15), new(3, 4, 1, 24), new(1, 1, 0, 29),
+ new(1, 1, 0, 19), new(1, 1, 0, 25), new(1, 1, 0, 25),
+ new(1, 1, 0, 26), new(1, 1, 0, 32), 34,
+ new(3, 2, 1, 45), new(3, 2, 1, 51)),
+ null,
+ new(new(0, 1, 0, 10), new(0, 1, 0, 11), new(0, 1, 0, 10), new(0, 1, 0, 20),
+ new(0, 1, 0, 17), new(0, 1, 0, 23), new(0, 1, 0, 16),
+ new(0, 1, 0, 17), new(0, 1, 0, 23), 16,
+ new(0, 1, 0, 17), new(0, 1, 0, 23)),
+ ];
+
+ public static int CloseDefence(byte race, byte level, byte armorLevel, int meleeGrowth = 0) =>
+ For(race).Close.At(level, meleeGrowth, DefenceGrowthDivisor) + (ArmorDefencePerLevel * armorLevel) + FloorDivide((2 * armorLevel) + 10, 25);
+
+ public static int DistanceDefence(byte race, byte level, byte armorLevel, int rangedGrowth = 0) =>
+ For(race).Distance.At(level, rangedGrowth, DefenceGrowthDivisor) + (ArmorDefencePerLevel * armorLevel) + FloorDivide((9 * armorLevel) + 20, 25);
+
+ public static int MagicDefence(byte race, byte level, byte armorLevel, int magicGrowth = 0) =>
+ For(race).Magic.At(level, magicGrowth, DefenceGrowthDivisor) + (ArmorDefencePerLevel * armorLevel) + FloorDivide(armorLevel + 5, 25);
+
+ public static int Dodge(byte race, byte level, byte armorLevel, int rangedGrowth = 0) =>
+ For(race).Dodge.At(level, rangedGrowth, AttackGrowthDivisor) + (ArmorDodgePerLevel * armorLevel);
+
+ public static int MinimumDamage(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass, int growth = 0)
+ {
+ var curves = For(race);
+ var curve = attackClass switch
+ {
+ MonsterAttackClass.Ranged => curves.RangedMinimum,
+ MonsterAttackClass.Magic => curves.MagicMinimum,
+ _ => curves.MeleeMinimum,
+ };
+ return curve.At(level, growth, AttackGrowthDivisor) + (WeaponMinimumDamagePerLevel * weaponLevel) + WeaponUpgradeBonus(weaponLevel);
+ }
+
+ public static int MaximumDamage(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass, int growth = 0)
+ {
+ var curves = For(race);
+ var curve = attackClass switch
+ {
+ MonsterAttackClass.Ranged => curves.RangedMaximum,
+ MonsterAttackClass.Magic => curves.MagicMaximum,
+ _ => curves.MeleeMaximum,
+ };
+ return curve.At(level, growth, AttackGrowthDivisor) + (WeaponMaximumDamagePerLevel * weaponLevel) - WeaponUpgradeBonus(weaponLevel);
+ }
+
+ public static int HitRate(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass, int rangedGrowth = 0)
+ {
+ var curves = For(race);
+ return attackClass switch
+ {
+ MonsterAttackClass.Magic => MagicHitRate,
+ MonsterAttackClass.Ranged => (2 * curves.MeleeHitRate.LevelShare(level, rangedGrowth, AttackGrowthDivisor)) + (WeaponHitRatePerLevel * weaponLevel) + curves.RangedHitRateConstant,
+ _ => curves.MeleeHitRate.At(level, rangedGrowth, AttackGrowthDivisor) + (WeaponHitRatePerLevel * weaponLevel),
+ };
+ }
+
+ private static RaceCurves For(byte race) => race < Races.Length ? Races[race] ?? Default : Default;
+
+ private static int WeaponUpgradeBonus(byte weaponLevel) => FloorDivide(weaponLevel - 1, 5);
+
+ private static int FloorDivide(int value, int divisor) =>
+ value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
+
+ ///
+ /// The race's share of the level, floor((numerator * level + offset) / denominator), plus a constant. A
+ /// growth factor adds growth / growthDivisor per level above the first inside the same floor.
+ ///
+ private readonly record struct Curve(int Numerator, int Denominator, int Offset, int Constant)
+ {
+ public int LevelShare(byte level, int growth, int growthDivisor) =>
+ FloorDivide((((Numerator * level) + Offset) * growthDivisor) + (Denominator * growth * (level - 1)), Denominator * growthDivisor);
+
+ public int At(byte level, int growth, int growthDivisor) => LevelShare(level, growth, growthDivisor) + Constant;
+ }
+
+ private sealed record RaceCurves(
+ Curve Close, Curve Distance, Curve Magic, Curve Dodge,
+ Curve MeleeMinimum, Curve MeleeMaximum, Curve MeleeHitRate,
+ Curve RangedMinimum, Curve RangedMaximum, int RangedHitRateConstant,
+ Curve MagicMinimum, Curve MagicMaximum);
+ }
+}
diff --git a/src/NosCore.Algorithm/MonsterStatService/MonsterStatService.cs b/src/NosCore.Algorithm/MonsterStatService/MonsterStatService.cs
index 31edbcc..3cb2f22 100644
--- a/src/NosCore.Algorithm/MonsterStatService/MonsterStatService.cs
+++ b/src/NosCore.Algorithm/MonsterStatService/MonsterStatService.cs
@@ -19,12 +19,6 @@ namespace NosCore.Algorithm.MonsterStatService
///
public class MonsterStatService(IMateHpService mateHpService, IMateMpService mateMpService) : IMonsterStatService
{
- private const int ArmorDefencePerLevel = 2;
- private const int ArmorDodgePerLevel = 4;
- private const int WeaponMinimumDamagePerLevel = 3;
- private const int WeaponMaximumDamagePerLevel = 5;
- private const int WeaponHitRatePerLevel = 4;
- private const int MagicHitRate = 70;
private const int CriticalChance = 4;
private const int CriticalRate = 70;
@@ -37,48 +31,6 @@ private static readonly (byte Level, int Tenths)[] HpTiers =
(37, 12),
];
- private static readonly RaceCurves Default = new(
- new(1, 2, 1, 15), new(1, 2, 0, 14), new(1, 2, 1, 10), new(1, 1, 0, 26),
- new(1, 1, 0, 28), new(1, 1, 0, 34), new(1, 1, 0, 22),
- new(1, 1, 0, 23), new(1, 1, 0, 29), 28,
- new(1, 1, 0, 18), new(1, 1, 0, 24));
-
- // Race 4 has two level 30 entries whose published stats fit none of these curves, so the low end
- // of that race is unconfirmed.
- private static readonly RaceCurves?[] Races =
- [
- Default,
- new(new(1, 1, 0, 19), new(1, 1, 0, 17), new(1, 1, 0, 18), new(2, 1, 0, 33),
- new(2, 1, 0, 35), new(2, 1, 0, 41), new(2, 1, 0, 29),
- new(2, 1, 0, 30), new(2, 1, 0, 36), 42,
- new(2, 1, 0, 33), new(2, 1, 0, 39)),
- new(new(3, 4, 1, 14), new(1, 2, 1, 15), new(2, 5, 3, 14), new(1, 1, 0, 29),
- new(3, 2, 1, 25), new(3, 2, 1, 31), new(1, 1, 0, 25),
- new(1, 1, 0, 26), new(1, 1, 0, 32), 34,
- new(4, 5, 1, 26), new(4, 5, 1, 32)),
- new(new(1, 2, 1, 14), new(1, 2, 1, 15), new(1, 2, 1, 14), new(1, 1, 0, 29),
- new(1, 1, 0, 26), new(1, 1, 0, 32), new(1, 1, 0, 25),
- new(1, 1, 0, 26), new(1, 1, 0, 32), 34,
- new(1, 1, 0, 26), new(1, 1, 0, 32)),
- new(new(3, 5, 4, 16), new(3, 5, 4, 17), new(1, 1, 0, 16), new(6, 5, 4, 33),
- new(6, 5, 4, 30), new(6, 5, 4, 36), new(6, 5, 4, 29),
- new(6, 5, 4, 30), new(6, 5, 4, 36), 42,
- new(2, 1, 0, 30), new(2, 1, 0, 36)),
- new(new(2, 5, 0, 13), new(2, 5, 0, 14), new(2, 5, 0, 13), new(4, 5, 1, 26),
- new(4, 5, 1, 23), new(4, 5, 1, 29), new(4, 5, 1, 22),
- new(4, 5, 1, 23), new(4, 5, 1, 29), 28,
- new(4, 5, 1, 23), new(4, 5, 1, 29)),
- new(new(1, 2, 0, 11), new(1, 2, 1, 15), new(3, 4, 1, 24), new(1, 1, 0, 29),
- new(1, 1, 0, 19), new(1, 1, 0, 25), new(1, 1, 0, 25),
- new(1, 1, 0, 26), new(1, 1, 0, 32), 34,
- new(3, 2, 1, 45), new(3, 2, 1, 51)),
- null,
- new(new(0, 1, 0, 10), new(0, 1, 0, 11), new(0, 1, 0, 10), new(0, 1, 0, 20),
- new(0, 1, 0, 17), new(0, 1, 0, 23), new(0, 1, 0, 16),
- new(0, 1, 0, 17), new(0, 1, 0, 23), 16,
- new(0, 1, 0, 17), new(0, 1, 0, 23)),
- ];
-
///
public long GetMaxHp(byte race, byte level)
{
@@ -99,86 +51,33 @@ public long GetMaxHp(byte race, byte level)
public long GetMaxMp(byte race, byte level) => mateMpService.GetMp(race, level, 0);
///
- public int GetCloseDefence(byte race, byte level, byte armorLevel) =>
- For(race).Close.At(level) + (ArmorDefencePerLevel * armorLevel) + FloorDivide((2 * armorLevel) + 10, 25);
+ public int GetCloseDefence(byte race, byte level, byte armorLevel) => MonsterCurves.CloseDefence(race, level, armorLevel);
///
- public int GetDistanceDefence(byte race, byte level, byte armorLevel) =>
- For(race).Distance.At(level) + (ArmorDefencePerLevel * armorLevel) + FloorDivide((9 * armorLevel) + 20, 25);
+ public int GetDistanceDefence(byte race, byte level, byte armorLevel) => MonsterCurves.DistanceDefence(race, level, armorLevel);
///
- public int GetMagicDefence(byte race, byte level, byte armorLevel) =>
- For(race).Magic.At(level) + (ArmorDefencePerLevel * armorLevel) + FloorDivide(armorLevel + 5, 25);
+ public int GetMagicDefence(byte race, byte level, byte armorLevel) => MonsterCurves.MagicDefence(race, level, armorLevel);
///
- public int GetDodge(byte race, byte level, byte armorLevel) =>
- For(race).Dodge.At(level) + (ArmorDodgePerLevel * armorLevel);
+ public int GetDodge(byte race, byte level, byte armorLevel) => MonsterCurves.Dodge(race, level, armorLevel);
///
- public int GetMinimumDamage(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass)
- {
- var curves = For(race);
- var curve = attackClass switch
- {
- MonsterAttackClass.Ranged => curves.RangedMinimum,
- MonsterAttackClass.Magic => curves.MagicMinimum,
- _ => curves.MeleeMinimum,
- };
- return curve.At(level) + (WeaponMinimumDamagePerLevel * weaponLevel) + WeaponUpgradeBonus(weaponLevel);
- }
+ public int GetMinimumDamage(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass) =>
+ MonsterCurves.MinimumDamage(race, level, weaponLevel, attackClass);
///
- public int GetMaximumDamage(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass)
- {
- var curves = For(race);
- var curve = attackClass switch
- {
- MonsterAttackClass.Ranged => curves.RangedMaximum,
- MonsterAttackClass.Magic => curves.MagicMaximum,
- _ => curves.MeleeMaximum,
- };
- return curve.At(level) + (WeaponMaximumDamagePerLevel * weaponLevel) - WeaponUpgradeBonus(weaponLevel);
- }
+ public int GetMaximumDamage(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass) =>
+ MonsterCurves.MaximumDamage(race, level, weaponLevel, attackClass);
///
- public int GetHitRate(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass)
- {
- var curves = For(race);
- return attackClass switch
- {
- MonsterAttackClass.Magic => MagicHitRate,
- MonsterAttackClass.Ranged => (2 * curves.MeleeHitRate.LevelShare(level)) + (WeaponHitRatePerLevel * weaponLevel) + curves.RangedHitRateConstant,
- _ => curves.MeleeHitRate.At(level) + (WeaponHitRatePerLevel * weaponLevel),
- };
- }
+ public int GetHitRate(byte race, byte level, byte weaponLevel, MonsterAttackClass attackClass) =>
+ MonsterCurves.HitRate(race, level, weaponLevel, attackClass);
///
public int GetCriticalChance() => CriticalChance;
///
public int GetCriticalRate() => CriticalRate;
-
- private static RaceCurves For(byte race) => race < Races.Length ? Races[race] ?? Default : Default;
-
- private static int WeaponUpgradeBonus(byte weaponLevel) => FloorDivide(weaponLevel - 1, 5);
-
- private static int FloorDivide(int value, int divisor) =>
- value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
-
- ///
- /// The race's share of the monster level, floor((numerator * level + offset) / denominator), plus a constant
- ///
- private readonly record struct Curve(int Numerator, int Denominator, int Offset, int Constant)
- {
- public int LevelShare(byte level) => FloorDivide((Numerator * level) + Offset, Denominator);
-
- public int At(byte level) => LevelShare(level) + Constant;
- }
-
- private sealed record RaceCurves(
- Curve Close, Curve Distance, Curve Magic, Curve Dodge,
- Curve MeleeMinimum, Curve MeleeMaximum, Curve MeleeHitRate,
- Curve RangedMinimum, Curve RangedMaximum, int RangedHitRateConstant,
- Curve MagicMinimum, Curve MagicMaximum);
}
}
diff --git a/test/NosCore.Algorithm.Tests/DocumentationTest.cs b/test/NosCore.Algorithm.Tests/DocumentationTest.cs
index b4e64ba..639bcde 100644
--- a/test/NosCore.Algorithm.Tests/DocumentationTest.cs
+++ b/test/NosCore.Algorithm.Tests/DocumentationTest.cs
@@ -831,5 +831,61 @@ public void MonsterStatDocumentation()
Approvals.Verify(WriterFactory.CreateTextWriter(resultBuilder.ToString(), "md"));
}
+
+ [TestMethod]
+ public void MateStatDocumentation()
+ {
+ var service = new MateStatService.MateStatService();
+ var melee = MonsterStatService.MonsterAttackClass.Melee;
+ var ranged = MonsterStatService.MonsterAttackClass.Ranged;
+ var magic = MonsterStatService.MonsterAttackClass.Magic;
+
+ // Published pet stats with the entries' flat modifiers taken off: a level 40 race 2 melee pet growing
+ // 12/5/2, a level 99 race 2 archer growing 4/12/0 and a level 99 race 0 melee pet growing 1/10/0.
+ var brawler = new MateStatService.MateGrowth(12, 5, 2);
+ Assert.AreEqual(150, service.GetCloseDefence(2, 40, brawler));
+ Assert.AreEqual(140, service.GetDistanceDefence(2, 40, brawler));
+ Assert.AreEqual(115, service.GetMagicDefence(2, 40, brawler));
+ Assert.AreEqual(248, service.GetDodge(2, 40, brawler));
+ Assert.AreEqual(259, service.GetMinimumDamage(2, 40, melee, brawler));
+ Assert.AreEqual(331, service.GetMaximumDamage(2, 40, melee, brawler));
+ Assert.AreEqual(244, service.GetHitRate(2, 40, melee, brawler));
+ var archer = new MateStatService.MateGrowth(4, 12, 0);
+ Assert.AreEqual(314, service.GetCloseDefence(2, 99, archer));
+ Assert.AreEqual(357, service.GetDistanceDefence(2, 99, archer));
+ Assert.AreEqual(641, service.GetDodge(2, 99, archer));
+ Assert.AreEqual(558, service.GetMinimumDamage(2, 99, ranged, archer));
+ Assert.AreEqual(724, service.GetMaximumDamage(2, 99, ranged, archer));
+ Assert.AreEqual(862, service.GetHitRate(2, 99, ranged, archer));
+ var sharpshooter = new MateStatService.MateGrowth(1, 10, 0);
+ Assert.AreEqual(275, service.GetCloseDefence(0, 99, sharpshooter));
+ Assert.AreEqual(346, service.GetDistanceDefence(0, 99, sharpshooter));
+ Assert.AreEqual(619, service.GetDodge(0, 99, sharpshooter));
+ Assert.AreEqual(452, service.GetMinimumDamage(0, 99, melee, sharpshooter));
+ Assert.AreEqual(615, service.GetHitRate(0, 99, melee, sharpshooter));
+ Assert.AreEqual(136, service.GetMagicDefence(2, 40, new MateStatService.MateGrowth(4, 0, 13)));
+ Assert.AreEqual(235, service.GetMinimumDamage(2, 40, magic, new MateStatService.MateGrowth(4, 0, 13)));
+
+ var resultBuilder = new StringBuilder("# Pet Stat Table");
+ resultBuilder.AppendLine();
+ resultBuilder.AppendLine();
+ resultBuilder.AppendLine("Race 0, before the entry's flat modifiers, for a few growth factors (melee/ranged/magic).");
+ MateStatService.MateGrowth[] growths = { new(0, 0, 0), new(10, 0, 0), new(0, 10, 0), new(0, 0, 10), new(5, 5, 5) };
+ byte[] levels = { 1, 2, 10, 20, 40, 60, 80, 99 };
+ foreach (var growth in growths)
+ {
+ resultBuilder.AppendLine();
+ resultBuilder.AppendLine($"## Growth {growth.Melee}/{growth.Ranged}/{growth.Magic}");
+ foreach (var level in levels)
+ {
+ var classes = string.Join(" - ", Enum.GetValues().Select(attackClass =>
+ $"{attackClass}: {service.GetMinimumDamage(0, level, attackClass, growth)}~{service.GetMaximumDamage(0, level, attackClass, growth)} hit {service.GetHitRate(0, level, attackClass, growth)}"));
+ resultBuilder.AppendLine(
+ $"- Level {level,2} - Defence: {service.GetCloseDefence(0, level, growth)}/{service.GetDistanceDefence(0, level, growth)}/{service.GetMagicDefence(0, level, growth)} - Dodge: {service.GetDodge(0, level, growth)} - {classes}");
+ }
+ }
+
+ Approvals.Verify(WriterFactory.CreateTextWriter(resultBuilder.ToString(), "md"));
+ }
}
}