feat(bar): implement Barbarian Combat Masteries (IDs 127-129, 134-136, 141, 145, 148, 153)
- Authoritative 1.13c parity for 10 Barbarian Combat Masteries - Exact linear AR, Damage, Stamina, Defense progression - Exact diminishing returns Critical Strike (dm56), Speed, Resist All (dm12) - Sequential roll for Critical Strike & Deadly Strike - Adversarial stress tests (slvl 0, 1, 10, 20, 99) - Enforces BATCH1_SKILLS length invariant = strictly 38 - Fixes #465
This commit is contained in:
parent
bd4920f3aa
commit
0c3388b18b
|
|
@ -0,0 +1,474 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c — Barbarian Combat Masteries Module
|
||||||
|
*
|
||||||
|
* Dedicated pure calculation and kinematics formulas for the 10 Barbarian Combat Masteries:
|
||||||
|
* - Skill 127: Sword Mastery (ReqLevel 1)
|
||||||
|
* - Skill 128: Axe Mastery (ReqLevel 1)
|
||||||
|
* - Skill 129: Mace Mastery (ReqLevel 1)
|
||||||
|
* - Skill 134: Polearm Mastery (ReqLevel 6)
|
||||||
|
* - Skill 135: Throwing Mastery (ReqLevel 6)
|
||||||
|
* - Skill 136: Spear Mastery (ReqLevel 6)
|
||||||
|
* - Skill 141: Increased Stamina (ReqLevel 12)
|
||||||
|
* - Skill 145: Iron Skin (ReqLevel 18)
|
||||||
|
* - Skill 148: Increased Speed (ReqLevel 24)
|
||||||
|
* - Skill 153: Natural Resistance (ReqLevel 30)
|
||||||
|
*
|
||||||
|
* 1.13c Ground Truth:
|
||||||
|
* - Skills.txt (Patch_D2.mpq)
|
||||||
|
* - Exact linear progressions ln12 (AR), ln34 (Dmg), ln12 (Stamina, Defense)
|
||||||
|
* - Diminishing returns dm56 (Crit), dm12 (Speed, Resist All)
|
||||||
|
* - Sequential roll for Critical Strike & Deadly Strike (no double-crit / 4x dmg)
|
||||||
|
* - Zero runtime MPQ reading
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computeDiminishingReturns } from '../engine/calc-ast.ts'
|
||||||
|
|
||||||
|
// --- Interfaces for Skill Calculations ---
|
||||||
|
|
||||||
|
export interface WeaponMasteryStats {
|
||||||
|
readonly skillId: number
|
||||||
|
readonly skillName: string
|
||||||
|
readonly weaponCategory: string
|
||||||
|
readonly attackRatingBonusPct: number
|
||||||
|
readonly damageBonusPct: number
|
||||||
|
readonly criticalStrikeChancePct: number
|
||||||
|
readonly manaCost: number
|
||||||
|
readonly manaCost256: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncreasedStaminaStats {
|
||||||
|
readonly staminaBonusPct: number
|
||||||
|
readonly manaCost: number
|
||||||
|
readonly manaCost256: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IronSkinStats {
|
||||||
|
readonly defenseBonusPct: number
|
||||||
|
readonly manaCost: number
|
||||||
|
readonly manaCost256: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncreasedSpeedStats {
|
||||||
|
readonly runWalkSpeedBonusPct: number
|
||||||
|
readonly manaCost: number
|
||||||
|
readonly manaCost256: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NaturalResistanceStats {
|
||||||
|
readonly resistAllPct: number
|
||||||
|
readonly fireResistPct: number
|
||||||
|
readonly coldResistPct: number
|
||||||
|
readonly lightningResistPct: number
|
||||||
|
readonly poisonResistPct: number
|
||||||
|
readonly manaCost: number
|
||||||
|
readonly manaCost256: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Calculation Implementations ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 127: Sword Mastery
|
||||||
|
* AR: 28 + 8 * (slvl - 1)%
|
||||||
|
* Dmg: 28 + 5 * (slvl - 1)%
|
||||||
|
* Crit: dm(0, 35, slvl)%
|
||||||
|
*/
|
||||||
|
export function calculateSwordMasteryStats(slvl: number): WeaponMasteryStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
skillId: 127,
|
||||||
|
skillName: 'Sword Mastery',
|
||||||
|
weaponCategory: 'sword',
|
||||||
|
attackRatingBonusPct: 0,
|
||||||
|
damageBonusPct: 0,
|
||||||
|
criticalStrikeChancePct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: 127,
|
||||||
|
skillName: 'Sword Mastery',
|
||||||
|
weaponCategory: 'sword',
|
||||||
|
attackRatingBonusPct: 28 + 8 * (lvl - 1),
|
||||||
|
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||||
|
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 128: Axe Mastery
|
||||||
|
* AR: 28 + 8 * (slvl - 1)%
|
||||||
|
* Dmg: 28 + 5 * (slvl - 1)%
|
||||||
|
* Crit: dm(0, 35, slvl)%
|
||||||
|
*/
|
||||||
|
export function calculateAxeMasteryStats(slvl: number): WeaponMasteryStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
skillId: 128,
|
||||||
|
skillName: 'Axe Mastery',
|
||||||
|
weaponCategory: 'axe',
|
||||||
|
attackRatingBonusPct: 0,
|
||||||
|
damageBonusPct: 0,
|
||||||
|
criticalStrikeChancePct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: 128,
|
||||||
|
skillName: 'Axe Mastery',
|
||||||
|
weaponCategory: 'axe',
|
||||||
|
attackRatingBonusPct: 28 + 8 * (lvl - 1),
|
||||||
|
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||||
|
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 129: Mace Mastery
|
||||||
|
* AR: 28 + 8 * (slvl - 1)%
|
||||||
|
* Dmg: 28 + 5 * (slvl - 1)%
|
||||||
|
* Crit: dm(0, 35, slvl)%
|
||||||
|
* Applies to: Maces, Clubs, Hammers, Flails, Scepters
|
||||||
|
*/
|
||||||
|
export function calculateMaceMasteryStats(slvl: number): WeaponMasteryStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
skillId: 129,
|
||||||
|
skillName: 'Mace Mastery',
|
||||||
|
weaponCategory: 'mace',
|
||||||
|
attackRatingBonusPct: 0,
|
||||||
|
damageBonusPct: 0,
|
||||||
|
criticalStrikeChancePct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: 129,
|
||||||
|
skillName: 'Mace Mastery',
|
||||||
|
weaponCategory: 'mace',
|
||||||
|
attackRatingBonusPct: 28 + 8 * (lvl - 1),
|
||||||
|
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||||
|
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 134: Polearm Mastery
|
||||||
|
* AR: 30 + 8 * (slvl - 1)%
|
||||||
|
* Dmg: 28 + 5 * (slvl - 1)%
|
||||||
|
* Crit: dm(0, 35, slvl)%
|
||||||
|
*/
|
||||||
|
export function calculatePolearmMasteryStats(slvl: number): WeaponMasteryStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
skillId: 134,
|
||||||
|
skillName: 'Polearm Mastery',
|
||||||
|
weaponCategory: 'polearm',
|
||||||
|
attackRatingBonusPct: 0,
|
||||||
|
damageBonusPct: 0,
|
||||||
|
criticalStrikeChancePct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: 134,
|
||||||
|
skillName: 'Polearm Mastery',
|
||||||
|
weaponCategory: 'polearm',
|
||||||
|
attackRatingBonusPct: 30 + 8 * (lvl - 1),
|
||||||
|
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||||
|
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 135: Throwing Mastery
|
||||||
|
* AR: 30 + 8 * (slvl - 1)%
|
||||||
|
* Dmg: 28 + 5 * (slvl - 1)%
|
||||||
|
* Crit: dm(0, 35, slvl)%
|
||||||
|
* Applies to: Throwing Knives, Throwing Axes, Javelins (when thrown)
|
||||||
|
*/
|
||||||
|
export function calculateThrowingMasteryStats(slvl: number): WeaponMasteryStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
skillId: 135,
|
||||||
|
skillName: 'Throwing Mastery',
|
||||||
|
weaponCategory: 'throwing',
|
||||||
|
attackRatingBonusPct: 0,
|
||||||
|
damageBonusPct: 0,
|
||||||
|
criticalStrikeChancePct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: 135,
|
||||||
|
skillName: 'Throwing Mastery',
|
||||||
|
weaponCategory: 'throwing',
|
||||||
|
attackRatingBonusPct: 30 + 8 * (lvl - 1),
|
||||||
|
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||||
|
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 136: Spear Mastery
|
||||||
|
* AR: 30 + 8 * (slvl - 1)%
|
||||||
|
* Dmg: 28 + 5 * (slvl - 1)%
|
||||||
|
* Crit: dm(0, 35, slvl)%
|
||||||
|
* Applies to: Spears and Javelins (in melee)
|
||||||
|
*/
|
||||||
|
export function calculateSpearMasteryStats(slvl: number): WeaponMasteryStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
skillId: 136,
|
||||||
|
skillName: 'Spear Mastery',
|
||||||
|
weaponCategory: 'spear',
|
||||||
|
attackRatingBonusPct: 0,
|
||||||
|
damageBonusPct: 0,
|
||||||
|
criticalStrikeChancePct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: 136,
|
||||||
|
skillName: 'Spear Mastery',
|
||||||
|
weaponCategory: 'spear',
|
||||||
|
attackRatingBonusPct: 30 + 8 * (lvl - 1),
|
||||||
|
damageBonusPct: 28 + 5 * (lvl - 1),
|
||||||
|
criticalStrikeChancePct: computeDiminishingReturns(0, 35, lvl),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 141: Increased Stamina
|
||||||
|
* Stamina %: 30 + 15 * (slvl - 1)%
|
||||||
|
*/
|
||||||
|
export function calculateIncreasedStaminaStats(slvl: number): IncreasedStaminaStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
staminaBonusPct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
staminaBonusPct: 30 + 15 * (lvl - 1),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 145: Iron Skin
|
||||||
|
* Defense %: 30 + 10 * (slvl - 1)%
|
||||||
|
*/
|
||||||
|
export function calculateIronSkinStats(slvl: number): IronSkinStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
defenseBonusPct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defenseBonusPct: 30 + 10 * (lvl - 1),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 148: Increased Speed
|
||||||
|
* Velocity %: dm(7, 50, slvl)%
|
||||||
|
*/
|
||||||
|
export function calculateIncreasedSpeedStats(slvl: number): IncreasedSpeedStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
runWalkSpeedBonusPct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runWalkSpeedBonusPct: computeDiminishingReturns(7, 50, lvl),
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 153: Natural Resistance
|
||||||
|
* Elemental Resistances (Fire, Cold, Lightning, Poison): dm(0, 80, slvl)%
|
||||||
|
*/
|
||||||
|
export function calculateNaturalResistanceStats(slvl: number): NaturalResistanceStats {
|
||||||
|
const lvl = Math.max(0, Math.floor(slvl))
|
||||||
|
if (lvl === 0) {
|
||||||
|
return {
|
||||||
|
resistAllPct: 0,
|
||||||
|
fireResistPct: 0,
|
||||||
|
coldResistPct: 0,
|
||||||
|
lightningResistPct: 0,
|
||||||
|
poisonResistPct: 0,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = computeDiminishingReturns(0, 80, lvl)
|
||||||
|
return {
|
||||||
|
resistAllPct: res,
|
||||||
|
fireResistPct: res,
|
||||||
|
coldResistPct: res,
|
||||||
|
lightningResistPct: res,
|
||||||
|
poisonResistPct: res,
|
||||||
|
manaCost: 0,
|
||||||
|
manaCost256: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Weapon & Crit Evaluation Helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a given weapon type string matches the target Barbarian Mastery skill.
|
||||||
|
*/
|
||||||
|
export function isWeaponMatchingMastery(weaponType: string, skillId: number): boolean {
|
||||||
|
const norm = weaponType.toLowerCase().trim()
|
||||||
|
switch (skillId) {
|
||||||
|
case 127: // Sword Mastery
|
||||||
|
return (
|
||||||
|
norm.includes('sword') ||
|
||||||
|
norm.includes('blade') ||
|
||||||
|
norm.includes('falchion') ||
|
||||||
|
norm.includes('scimitar') ||
|
||||||
|
norm.includes('saber') ||
|
||||||
|
norm.includes('claymore') ||
|
||||||
|
norm.includes('zweihander')
|
||||||
|
)
|
||||||
|
case 128: // Axe Mastery
|
||||||
|
return norm.includes('axe') || norm.includes('cleaver') || norm.includes('hatchet')
|
||||||
|
case 129: // Mace Mastery
|
||||||
|
return (
|
||||||
|
norm.includes('mace') ||
|
||||||
|
norm.includes('club') ||
|
||||||
|
norm.includes('hammer') ||
|
||||||
|
norm.includes('flail') ||
|
||||||
|
norm.includes('scepter') ||
|
||||||
|
norm.includes('morning star')
|
||||||
|
)
|
||||||
|
case 134: // Polearm Mastery
|
||||||
|
return (
|
||||||
|
norm.includes('polearm') ||
|
||||||
|
norm.includes('scythe') ||
|
||||||
|
norm.includes('voulge') ||
|
||||||
|
norm.includes('thresher') ||
|
||||||
|
norm.includes('halberd') ||
|
||||||
|
norm.includes('bardiche') ||
|
||||||
|
norm.includes('lochaber') ||
|
||||||
|
norm.includes('bill')
|
||||||
|
)
|
||||||
|
case 135: // Throwing Mastery
|
||||||
|
return (
|
||||||
|
norm.includes('throw') ||
|
||||||
|
norm.includes('javelin') ||
|
||||||
|
norm.includes('dart') ||
|
||||||
|
norm.includes('pilum') ||
|
||||||
|
norm.includes('harpoon')
|
||||||
|
)
|
||||||
|
case 136: // Spear Mastery
|
||||||
|
return (
|
||||||
|
norm.includes('spear') ||
|
||||||
|
norm.includes('pike') ||
|
||||||
|
norm.includes('lance') ||
|
||||||
|
norm.includes('trident') ||
|
||||||
|
norm.includes('javelin')
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates effective double-damage proc chance combining Mastery Critical Strike and Deadly Strike.
|
||||||
|
*
|
||||||
|
* In Diablo II 1.13c:
|
||||||
|
* - Critical Strike (Mastery/Amazon) rolls first.
|
||||||
|
* - If CS does not proc, Deadly Strike (items) rolls second.
|
||||||
|
* - They NEVER stack to quadruple damage; damage is doubled at most once:
|
||||||
|
* P(double damage) = CS% + DS% * (1 - CS% / 100)
|
||||||
|
*/
|
||||||
|
export function calculateEffectiveCriticalStrike(
|
||||||
|
masteryCritPct: number,
|
||||||
|
deadlyStrikePct: number
|
||||||
|
): number {
|
||||||
|
const cs = Math.max(0, Math.min(100, masteryCritPct)) / 100
|
||||||
|
const ds = Math.max(0, Math.min(100, deadlyStrikePct)) / 100
|
||||||
|
const combined = cs + ds * (1 - cs)
|
||||||
|
return Math.round(combined * 1000) / 10 // rounded to 1 decimal place %
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified dispatch evaluation for Barbarian Combat Masteries.
|
||||||
|
*/
|
||||||
|
export function evaluateBarbarianMastery(
|
||||||
|
skillId: number,
|
||||||
|
slvl: number
|
||||||
|
): WeaponMasteryStats | IncreasedStaminaStats | IronSkinStats | IncreasedSpeedStats | NaturalResistanceStats | null {
|
||||||
|
switch (skillId) {
|
||||||
|
case 127:
|
||||||
|
return calculateSwordMasteryStats(slvl)
|
||||||
|
case 128:
|
||||||
|
return calculateAxeMasteryStats(slvl)
|
||||||
|
case 129:
|
||||||
|
return calculateMaceMasteryStats(slvl)
|
||||||
|
case 134:
|
||||||
|
return calculatePolearmMasteryStats(slvl)
|
||||||
|
case 135:
|
||||||
|
return calculateThrowingMasteryStats(slvl)
|
||||||
|
case 136:
|
||||||
|
return calculateSpearMasteryStats(slvl)
|
||||||
|
case 141:
|
||||||
|
return calculateIncreasedStaminaStats(slvl)
|
||||||
|
case 145:
|
||||||
|
return calculateIronSkinStats(slvl)
|
||||||
|
case 148:
|
||||||
|
return calculateIncreasedSpeedStats(slvl)
|
||||||
|
case 153:
|
||||||
|
return calculateNaturalResistanceStats(slvl)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c — Milestone M18
|
||||||
|
* Barbarian Combat Masteries Adversarial Stress & Parity Suite
|
||||||
|
*
|
||||||
|
* Ground Truth:
|
||||||
|
* - Skills.txt (Patch_D2.mpq)
|
||||||
|
* - Exact linear progressions ln12 (AR), ln34 (Dmg), ln12 (Stamina, Defense)
|
||||||
|
* - Diminishing returns dm56 (Crit), dm12 (Speed, Resist All)
|
||||||
|
* - Sequential roll for Critical Strike & Deadly Strike
|
||||||
|
* - Strict BATCH1_SKILLS invariant: length === 38
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
calculateSwordMasteryStats,
|
||||||
|
calculateAxeMasteryStats,
|
||||||
|
calculateMaceMasteryStats,
|
||||||
|
calculatePolearmMasteryStats,
|
||||||
|
calculateThrowingMasteryStats,
|
||||||
|
calculateSpearMasteryStats,
|
||||||
|
calculateIncreasedStaminaStats,
|
||||||
|
calculateIronSkinStats,
|
||||||
|
calculateIncreasedSpeedStats,
|
||||||
|
calculateNaturalResistanceStats,
|
||||||
|
isWeaponMatchingMastery,
|
||||||
|
calculateEffectiveCriticalStrike,
|
||||||
|
evaluateBarbarianMastery,
|
||||||
|
} from '../../../src/game/skills/barbarian-masteries.ts'
|
||||||
|
import { BATCH1_SKILLS } from '../../../src/game/skills.ts'
|
||||||
|
|
||||||
|
describe('Milestone M18 — Barbarian Combat Masteries Parity & Adversarial Stress Suite', () => {
|
||||||
|
describe('Dimension 1: Exact 1.13c Numerical Scaling & Formulas', () => {
|
||||||
|
it('1.1 Sword Mastery (127): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateSwordMasteryStats(1)
|
||||||
|
expect(s1.attackRatingBonusPct).toBe(28)
|
||||||
|
expect(s1.damageBonusPct).toBe(28)
|
||||||
|
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||||
|
expect(s1.manaCost).toBe(0)
|
||||||
|
|
||||||
|
const s10 = calculateSwordMasteryStats(10)
|
||||||
|
expect(s10.attackRatingBonusPct).toBe(100) // 28 + 8 * 9
|
||||||
|
expect(s10.damageBonusPct).toBe(73) // 28 + 5 * 9
|
||||||
|
expect(s10.criticalStrikeChancePct).toBe(24) // 110*10*35 / (100*16) = 24.0625 -> 24
|
||||||
|
|
||||||
|
const s20 = calculateSwordMasteryStats(20)
|
||||||
|
expect(s20.attackRatingBonusPct).toBe(180) // 28 + 8 * 19
|
||||||
|
expect(s20.damageBonusPct).toBe(123) // 28 + 5 * 19
|
||||||
|
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.2 Axe Mastery (128): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateAxeMasteryStats(1)
|
||||||
|
expect(s1.attackRatingBonusPct).toBe(28)
|
||||||
|
expect(s1.damageBonusPct).toBe(28)
|
||||||
|
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||||
|
|
||||||
|
const s10 = calculateAxeMasteryStats(10)
|
||||||
|
expect(s10.attackRatingBonusPct).toBe(100)
|
||||||
|
expect(s10.damageBonusPct).toBe(73)
|
||||||
|
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||||
|
|
||||||
|
const s20 = calculateAxeMasteryStats(20)
|
||||||
|
expect(s20.attackRatingBonusPct).toBe(180)
|
||||||
|
expect(s20.damageBonusPct).toBe(123)
|
||||||
|
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.3 Mace Mastery (129): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateMaceMasteryStats(1)
|
||||||
|
expect(s1.attackRatingBonusPct).toBe(28)
|
||||||
|
expect(s1.damageBonusPct).toBe(28)
|
||||||
|
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||||
|
|
||||||
|
const s10 = calculateMaceMasteryStats(10)
|
||||||
|
expect(s10.attackRatingBonusPct).toBe(100)
|
||||||
|
expect(s10.damageBonusPct).toBe(73)
|
||||||
|
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||||
|
|
||||||
|
const s20 = calculateMaceMasteryStats(20)
|
||||||
|
expect(s20.attackRatingBonusPct).toBe(180)
|
||||||
|
expect(s20.damageBonusPct).toBe(123)
|
||||||
|
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.4 Polearm Mastery (134): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculatePolearmMasteryStats(1)
|
||||||
|
expect(s1.attackRatingBonusPct).toBe(30) // Param1 = 30
|
||||||
|
expect(s1.damageBonusPct).toBe(28)
|
||||||
|
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||||
|
|
||||||
|
const s10 = calculatePolearmMasteryStats(10)
|
||||||
|
expect(s10.attackRatingBonusPct).toBe(102) // 30 + 8 * 9
|
||||||
|
expect(s10.damageBonusPct).toBe(73)
|
||||||
|
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||||
|
|
||||||
|
const s20 = calculatePolearmMasteryStats(20)
|
||||||
|
expect(s20.attackRatingBonusPct).toBe(182) // 30 + 8 * 19
|
||||||
|
expect(s20.damageBonusPct).toBe(123)
|
||||||
|
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.5 Throwing Mastery (135): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateThrowingMasteryStats(1)
|
||||||
|
expect(s1.attackRatingBonusPct).toBe(30)
|
||||||
|
expect(s1.damageBonusPct).toBe(28)
|
||||||
|
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||||
|
|
||||||
|
const s10 = calculateThrowingMasteryStats(10)
|
||||||
|
expect(s10.attackRatingBonusPct).toBe(102)
|
||||||
|
expect(s10.damageBonusPct).toBe(73)
|
||||||
|
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||||
|
|
||||||
|
const s20 = calculateThrowingMasteryStats(20)
|
||||||
|
expect(s20.attackRatingBonusPct).toBe(182)
|
||||||
|
expect(s20.damageBonusPct).toBe(123)
|
||||||
|
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.6 Spear Mastery (136): Evaluates exact AR %, Dmg %, and Crit % at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateSpearMasteryStats(1)
|
||||||
|
expect(s1.attackRatingBonusPct).toBe(30)
|
||||||
|
expect(s1.damageBonusPct).toBe(28)
|
||||||
|
expect(s1.criticalStrikeChancePct).toBe(5)
|
||||||
|
|
||||||
|
const s10 = calculateSpearMasteryStats(10)
|
||||||
|
expect(s10.attackRatingBonusPct).toBe(102)
|
||||||
|
expect(s10.damageBonusPct).toBe(73)
|
||||||
|
expect(s10.criticalStrikeChancePct).toBe(24)
|
||||||
|
|
||||||
|
const s20 = calculateSpearMasteryStats(20)
|
||||||
|
expect(s20.attackRatingBonusPct).toBe(182)
|
||||||
|
expect(s20.damageBonusPct).toBe(123)
|
||||||
|
expect(s20.criticalStrikeChancePct).toBe(29)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.7 Increased Stamina (141): Evaluates stamina % bonus at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateIncreasedStaminaStats(1)
|
||||||
|
expect(s1.staminaBonusPct).toBe(30)
|
||||||
|
expect(s1.manaCost).toBe(0)
|
||||||
|
|
||||||
|
const s10 = calculateIncreasedStaminaStats(10)
|
||||||
|
expect(s10.staminaBonusPct).toBe(165) // 30 + 15 * 9
|
||||||
|
|
||||||
|
const s20 = calculateIncreasedStaminaStats(20)
|
||||||
|
expect(s20.staminaBonusPct).toBe(315) // 30 + 15 * 19
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.8 Iron Skin (145): Evaluates defense % bonus at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateIronSkinStats(1)
|
||||||
|
expect(s1.defenseBonusPct).toBe(30)
|
||||||
|
expect(s1.manaCost).toBe(0)
|
||||||
|
|
||||||
|
const s10 = calculateIronSkinStats(10)
|
||||||
|
expect(s10.defenseBonusPct).toBe(120) // 30 + 10 * 9
|
||||||
|
|
||||||
|
const s20 = calculateIronSkinStats(20)
|
||||||
|
expect(s20.defenseBonusPct).toBe(220) // 30 + 10 * 19
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.9 Increased Speed (148): Evaluates run/walk velocity % at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateIncreasedSpeedStats(1)
|
||||||
|
expect(s1.runWalkSpeedBonusPct).toBe(13) // dm(7, 50, 1) = 7 + floor(43 * 15 / 100) = 13
|
||||||
|
expect(s1.manaCost).toBe(0)
|
||||||
|
|
||||||
|
const s10 = calculateIncreasedSpeedStats(10)
|
||||||
|
expect(s10.runWalkSpeedBonusPct).toBe(36) // dm(7, 50, 10) = 7 + floor(43 * 68 / 100) = 36
|
||||||
|
|
||||||
|
const s20 = calculateIncreasedSpeedStats(20)
|
||||||
|
expect(s20.runWalkSpeedBonusPct).toBe(43) // dm(7, 50, 20) = 7 + floor(43 * 84 / 100) = 43
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1.10 Natural Resistance (153): Evaluates resist all % at slvl 1, 10, 20', () => {
|
||||||
|
const s1 = calculateNaturalResistanceStats(1)
|
||||||
|
expect(s1.resistAllPct).toBe(12) // dm(0, 80, 1) = floor(80 * 15 / 100) = 12
|
||||||
|
expect(s1.fireResistPct).toBe(12)
|
||||||
|
expect(s1.coldResistPct).toBe(12)
|
||||||
|
expect(s1.lightningResistPct).toBe(12)
|
||||||
|
expect(s1.poisonResistPct).toBe(12)
|
||||||
|
expect(s1.manaCost).toBe(0)
|
||||||
|
|
||||||
|
const s10 = calculateNaturalResistanceStats(10)
|
||||||
|
expect(s10.resistAllPct).toBe(55) // dm(0, 80, 10) = floor(110*10*80 / 1600) = 55
|
||||||
|
expect(s10.fireResistPct).toBe(55)
|
||||||
|
|
||||||
|
const s20 = calculateNaturalResistanceStats(20)
|
||||||
|
expect(s20.resistAllPct).toBe(67) // dm(0, 80, 20) = floor(80 * 84 / 100) = 67
|
||||||
|
expect(s20.resistAllPct).toBe(s20.poisonResistPct)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dimension 2: Mechanics & Weapon Interaction', () => {
|
||||||
|
it('2.1 Correctly identifies matching weapon categories', () => {
|
||||||
|
expect(isWeaponMatchingMastery('crystal sword', 127)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('colossus blade', 127)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('berserker axe', 127)).toBe(false)
|
||||||
|
|
||||||
|
expect(isWeaponMatchingMastery('berserker axe', 128)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('war club', 128)).toBe(false)
|
||||||
|
|
||||||
|
expect(isWeaponMatchingMastery('flail', 129)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('war hammer', 129)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('grand scepter', 129)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('short sword', 129)).toBe(false)
|
||||||
|
|
||||||
|
expect(isWeaponMatchingMastery('colossus voulge', 134)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('thresher', 134)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('polearm', 134)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('short sword', 134)).toBe(false)
|
||||||
|
|
||||||
|
expect(isWeaponMatchingMastery('throwing axe', 135)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('javelin', 135)).toBe(true)
|
||||||
|
|
||||||
|
expect(isWeaponMatchingMastery('war spear', 136)).toBe(true)
|
||||||
|
expect(isWeaponMatchingMastery('javelin', 136)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2.2 Enforces 1.13c Critical Strike + Deadly Strike sequential roll formula', () => {
|
||||||
|
// 20% CS, 50% DS -> 20 + 50 * (1 - 0.2) = 20 + 40 = 60%
|
||||||
|
expect(calculateEffectiveCriticalStrike(20, 50)).toBe(60.0)
|
||||||
|
|
||||||
|
// 0% CS, 30% DS -> 30%
|
||||||
|
expect(calculateEffectiveCriticalStrike(0, 30)).toBe(30.0)
|
||||||
|
|
||||||
|
// 25% CS, 0% DS -> 25%
|
||||||
|
expect(calculateEffectiveCriticalStrike(25, 0)).toBe(25.0)
|
||||||
|
|
||||||
|
// 30% CS, 100% DS -> 30 + 100 * 0.7 = 100%
|
||||||
|
expect(calculateEffectiveCriticalStrike(30, 100)).toBe(100.0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dimension 3: Adversarial Stress & Boundary Sanitization', () => {
|
||||||
|
it('3.1 Handles zero and negative levels safely without NaN or crash', () => {
|
||||||
|
for (const slvl of [0, -1, -50]) {
|
||||||
|
const sw = calculateSwordMasteryStats(slvl)
|
||||||
|
expect(sw.attackRatingBonusPct).toBe(0)
|
||||||
|
expect(sw.damageBonusPct).toBe(0)
|
||||||
|
expect(sw.criticalStrikeChancePct).toBe(0)
|
||||||
|
|
||||||
|
const st = calculateIncreasedStaminaStats(slvl)
|
||||||
|
expect(st.staminaBonusPct).toBe(0)
|
||||||
|
|
||||||
|
const def = calculateIronSkinStats(slvl)
|
||||||
|
expect(def.defenseBonusPct).toBe(0)
|
||||||
|
|
||||||
|
const spd = calculateIncreasedSpeedStats(slvl)
|
||||||
|
expect(spd.runWalkSpeedBonusPct).toBe(0)
|
||||||
|
|
||||||
|
const res = calculateNaturalResistanceStats(slvl)
|
||||||
|
expect(res.resistAllPct).toBe(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3.2 Handles massive extreme levels (slvl 99) monotonically within 1.13c asymptotic limits', () => {
|
||||||
|
const sw99 = calculateSwordMasteryStats(99)
|
||||||
|
expect(sw99.criticalStrikeChancePct).toBeLessThanOrEqual(38) // dm(0, 35, 99) = 36 (asymptote 1.1 * 35 = 38.5)
|
||||||
|
expect(sw99.criticalStrikeChancePct).toBeGreaterThan(30)
|
||||||
|
|
||||||
|
const spd99 = calculateIncreasedSpeedStats(99)
|
||||||
|
expect(spd99.runWalkSpeedBonusPct).toBeLessThanOrEqual(55) // dm(7, 50, 99) = 51 (asymptote 7 + 1.1 * 43 = 54.3)
|
||||||
|
expect(spd99.runWalkSpeedBonusPct).toBeGreaterThan(45)
|
||||||
|
|
||||||
|
const res99 = calculateNaturalResistanceStats(99)
|
||||||
|
expect(res99.resistAllPct).toBeLessThanOrEqual(88) // dm(0, 80, 99) = 83 (asymptote 1.1 * 80 = 88)
|
||||||
|
expect(res99.resistAllPct).toBeGreaterThan(75)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dimension 4: Data-Driven Registry Integration', () => {
|
||||||
|
it('4.1 Verifies all 10 Barbarian Combat Masteries evaluate via evaluateBarbarianMastery', () => {
|
||||||
|
const masteryIds = [127, 128, 129, 134, 135, 136, 141, 145, 148, 153]
|
||||||
|
for (const id of masteryIds) {
|
||||||
|
const res = evaluateBarbarianMastery(id, 10)
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res?.manaCost).toBe(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4.2 Enforces BATCH1_SKILLS length invariant = strictly 38', () => {
|
||||||
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue